diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/crypto/CryptoConstants.java b/bramble-api/src/main/java/org/briarproject/bramble/api/crypto/CryptoConstants.java
index 3b12c5cf75ae93f0b9fa0f84f549c88c39c0a3d1..b9f772d2b03905307fabe5d5dab3401fc6a912e9 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/crypto/CryptoConstants.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/crypto/CryptoConstants.java
@@ -16,4 +16,10 @@ public interface CryptoConstants {
 	 * The maximum length of a signature in bytes.
 	 */
 	int MAX_SIGNATURE_BYTES = 64;
+
+	/**
+	 * The length of a MAC in bytes.
+	 */
+	int MAC_BYTES = SecretKey.LENGTH;
+
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java
index dd5874f6edcca3400a2922d3f1feb9e2db5f2b2f..f63bcb0a6baf91c708eaaa2dc26ea8fbde2c73c5 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java
@@ -2898,6 +2898,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			String sql = "UPDATE outgoingKeys SET active = true"
 					+ " WHERE transportId = ? AND keySetId = ?";
 			ps = txn.prepareStatement(sql);
+			ps.setString(1, t.getString());
+			ps.setInt(2, k.getInt());
 			int affected = ps.executeUpdate();
 			if (affected < 0 || affected > 1) throw new DbStateException();
 			ps.close();
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/ValidatorTestCase.java b/bramble-core/src/test/java/org/briarproject/bramble/test/ValidatorTestCase.java
index 799722344ba234014686b8549cd7c5bc7b6cb417..66a4a3b5630f14dd5b8f6448b320ebcd67894be8 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/ValidatorTestCase.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/ValidatorTestCase.java
@@ -1,17 +1,20 @@
 package org.briarproject.bramble.test;
 
 import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfList;
 import org.briarproject.bramble.api.data.MetadataEncoder;
+import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.sync.Group;
 import org.briarproject.bramble.api.sync.GroupId;
 import org.briarproject.bramble.api.sync.Message;
 import org.briarproject.bramble.api.sync.MessageId;
 import org.briarproject.bramble.api.system.Clock;
+import org.jmock.Expectations;
 
+import static org.briarproject.bramble.test.TestUtils.getAuthor;
 import static org.briarproject.bramble.test.TestUtils.getClientId;
 import static org.briarproject.bramble.test.TestUtils.getGroup;
-import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
-import static org.briarproject.bramble.test.TestUtils.getRandomId;
+import static org.briarproject.bramble.test.TestUtils.getMessage;
 
 public abstract class ValidatorTestCase extends BrambleMockTestCase {
 
@@ -24,10 +27,23 @@ public abstract class ValidatorTestCase extends BrambleMockTestCase {
 	protected final Group group = getGroup(getClientId());
 	protected final GroupId groupId = group.getId();
 	protected final byte[] descriptor = group.getDescriptor();
-	protected final MessageId messageId = new MessageId(getRandomId());
-	protected final long timestamp = 1234567890 * 1000L;
-	protected final byte[] raw = getRandomBytes(123);
-	protected final Message message =
-			new Message(messageId, groupId, timestamp, raw);
+	protected final Message message = getMessage(groupId);
+	protected final MessageId messageId = message.getId();
+	protected final long timestamp = message.getTimestamp();
+	protected final byte[] raw = message.getRaw();
+	protected final Author author = getAuthor();
+	protected final BdfList authorList = BdfList.of(
+			author.getFormatVersion(),
+			author.getName(),
+			author.getPublicKey()
+	);
 
-}
+	protected void expectParseAuthor(BdfList authorList, Author author)
+			throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).parseAndValidateAuthor(authorList);
+			will(returnValue(author));
+		}});
+	}
+
+}
\ No newline at end of file
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ConversationActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ConversationActivity.java
index 585b0fed9be5a448482568b7b21e7e962dfc157b..2af7fb8e54522643319eb4215391879c310b2edf 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ConversationActivity.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ConversationActivity.java
@@ -865,7 +865,8 @@ introductionOnboardingSeen();
 								"Unknown Request Type");
 				}
 				loadMessages();
-			} catch (DbException | FormatException e) {
+			} catch (DbException e) {
+				// TODO use more generic error message
 				introductionResponseError();
 				if (LOG.isLoggable(WARNING))
 					LOG.log(WARNING, e.toString(), e);
@@ -898,11 +899,14 @@ introductionOnboardingSeen();
 
 	@DatabaseExecutor
 	private void respondToIntroductionRequest(SessionId sessionId,
-			boolean accept, long time) throws DbException, FormatException {
-		if (accept) {
-			introductionManager.acceptIntroduction(contactId, sessionId, time);
-		} else {
-			introductionManager.declineIntroduction(contactId, sessionId, time);
+			boolean accept, long time) throws DbException {
+		try {
+			introductionManager
+					.respondToIntroduction(contactId, sessionId, time, accept);
+		} catch (ProtocolStateException e) {
+			if (LOG.isLoggable(WARNING))
+				LOG.log(WARNING, e.toString(), e);
+			introductionResponseError();
 		}
 	}
 
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java
index 3a76421b375e9c8f9b51ac4fa6976a923cd56582..7a9652a610f22ff4a994655458cdfbf33ff6cf3d 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java
@@ -2,6 +2,7 @@ package org.briarproject.briar.android.introduction;
 
 import android.content.Context;
 import android.os.Bundle;
+import android.support.annotation.Nullable;
 import android.support.v7.app.ActionBar;
 import android.view.LayoutInflater;
 import android.view.MenuItem;
@@ -11,7 +12,6 @@ import android.widget.ProgressBar;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import org.briarproject.bramble.api.FormatException;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.contact.ContactManager;
@@ -33,9 +33,10 @@ import im.delight.android.identicons.IdenticonDrawable;
 
 import static android.app.Activity.RESULT_OK;
 import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
 import static android.widget.Toast.LENGTH_SHORT;
 import static java.util.logging.Level.WARNING;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_INTRODUCTION_MESSAGE_LENGTH;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_REQUEST_MESSAGE_LENGTH;
 
 public class IntroductionMessageFragment extends BaseFragment
 		implements TextInputListener {
@@ -125,14 +126,15 @@ public class IntroductionMessageFragment extends BaseFragment
 						new ContactId(contactId1));
 				Contact c2 = contactManager.getContact(
 						new ContactId(contactId2));
-				setUpViews(c1, c2);
+				boolean possible = introductionManager.canIntroduce(c1, c2);
+				setUpViews(c1, c2, possible);
 			} catch (DbException e) {
 				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 			}
 		});
 	}
 
-	private void setUpViews(Contact c1, Contact c2) {
+	private void setUpViews(Contact c1, Contact c2, boolean possible) {
 		introductionActivity.runOnUiThreadUnlessDestroyed(() -> {
 			contact1 = c1;
 			contact2 = c2;
@@ -147,13 +149,22 @@ public class IntroductionMessageFragment extends BaseFragment
 			ui.contactName1.setText(c1.getAuthor().getName());
 			ui.contactName2.setText(c2.getAuthor().getName());
 
-			// set button action
-			ui.message.setListener(IntroductionMessageFragment.this);
-
-			// hide progress bar and show views
+			// hide progress bar
 			ui.progressBar.setVisibility(GONE);
-			ui.message.setSendButtonEnabled(true);
-			ui.message.showSoftKeyboard();
+
+			if (possible) {
+				// set button action
+				ui.message.setListener(IntroductionMessageFragment.this);
+
+				// show views
+				ui.notPossible.setVisibility(GONE);
+				ui.message.setVisibility(VISIBLE);
+				ui.message.setSendButtonEnabled(true);
+				ui.message.showSoftKeyboard();
+			} else {
+				ui.notPossible.setVisibility(VISIBLE);
+				ui.message.setVisibility(GONE);
+			}
 		});
 	}
 
@@ -175,7 +186,8 @@ public class IntroductionMessageFragment extends BaseFragment
 		ui.message.setSendButtonEnabled(false);
 
 		String msg = ui.message.getText().toString();
-		msg = StringUtils.truncateUtf8(msg, MAX_INTRODUCTION_MESSAGE_LENGTH);
+		if (msg.equals("")) msg = null;
+		else msg = StringUtils.truncateUtf8(msg, MAX_REQUEST_MESSAGE_LENGTH);
 		makeIntroduction(contact1, contact2, msg);
 
 		// don't wait for the introduction to be made before finishing activity
@@ -184,13 +196,14 @@ public class IntroductionMessageFragment extends BaseFragment
 		introductionActivity.supportFinishAfterTransition();
 	}
 
-	private void makeIntroduction(Contact c1, Contact c2, String msg) {
+	private void makeIntroduction(Contact c1, Contact c2,
+			@Nullable String msg) {
 		introductionActivity.runOnDbThread(() -> {
 			// actually make the introduction
 			try {
 				long timestamp = System.currentTimeMillis();
 				introductionManager.makeIntroduction(c1, c2, msg, timestamp);
-			} catch (DbException | FormatException e) {
+			} catch (DbException e) {
 				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 				introductionError();
 			}
@@ -208,6 +221,7 @@ public class IntroductionMessageFragment extends BaseFragment
 		private final ProgressBar progressBar;
 		private final CircleImageView avatar1, avatar2;
 		private final TextView contactName1, contactName2;
+		private final TextView notPossible;
 		private final TextInputView message;
 
 		private ViewHolder(View v) {
@@ -216,6 +230,7 @@ public class IntroductionMessageFragment extends BaseFragment
 			avatar2 = v.findViewById(R.id.avatarContact2);
 			contactName1 = v.findViewById(R.id.nameContact1);
 			contactName2 = v.findViewById(R.id.nameContact2);
+			notPossible = v.findViewById(R.id.introductionNotPossibleView);
 			message = v.findViewById(R.id.introductionMessageView);
 		}
 	}
diff --git a/briar-android/src/main/res/layout/introduction_message.xml b/briar-android/src/main/res/layout/introduction_message.xml
index e78e1c63980169f266bef00394d1232bdf245bd4..0cdc34ba86a400842a912bc59e6367b32c6095df 100644
--- a/briar-android/src/main/res/layout/introduction_message.xml
+++ b/briar-android/src/main/res/layout/introduction_message.xml
@@ -94,13 +94,25 @@
 			android:layout_gravity="center"
 			tools:visibility="gone"/>
 
+		<TextView
+			android:id="@+id/introductionNotPossibleView"
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content"
+			android:layout_margin="@dimen/margin_activity_horizontal"
+			android:text="@string/introduction_not_possible"
+			android:textSize="@dimen/text_size_large"
+			android:visibility="gone"
+			tools:visibility="visible"/>
+
 		<org.briarproject.briar.android.view.LargeTextInputView
 			android:id="@+id/introductionMessageView"
 			android:layout_width="match_parent"
 			android:layout_height="wrap_content"
+			android:visibility="gone"
 			app:buttonText="@string/introduction_button"
 			app:hint="@string/introduction_message_hint"
-			app:maxLines="5"/>
+			app:maxLines="5"
+			tools:visibility="visible"/>
 
 	</LinearLayout>
 </ScrollView>
\ No newline at end of file
diff --git a/briar-android/src/main/res/values/strings.xml b/briar-android/src/main/res/values/strings.xml
index d4b40f5bb8636cadbb67b1b4fe315222e0fbe11c..38b04cd534ba1ac46342e839d5f61b8e7d90514b 100644
--- a/briar-android/src/main/res/values/strings.xml
+++ b/briar-android/src/main/res/values/strings.xml
@@ -144,6 +144,7 @@
 	<string name="introduction_onboarding_title">Introduce your contacts</string>
 	<string name="introduction_onboarding_text">You can introduce your contacts to each other, so they don\'t need to meet in person to connect on Briar.</string>
 	<string name="introduction_activity_title">Select Contact</string>
+	<string name="introduction_not_possible">You already have one introduction in progress with these contacts. Please allow for this to finish first. If you or your contacts are rarely online, this can take some time.</string>
 	<string name="introduction_message_title">Introduce Contacts</string>
 	<string name="introduction_message_hint">Add a message (optional)</string>
 	<string name="introduction_button">Make Introduction</string>
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/client/MessageQueueManager.java b/briar-api/src/main/java/org/briarproject/briar/api/client/MessageQueueManager.java
deleted file mode 100644
index 75418bf7c27afd1375a2156d6ac6b1f15388805e..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/client/MessageQueueManager.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.briarproject.briar.api.client;
-
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Metadata;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.ClientId;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.InvalidMessageException;
-import org.briarproject.bramble.api.sync.MessageContext;
-
-@Deprecated
-@NotNullByDefault
-public interface MessageQueueManager {
-
-	/**
-	 * The key used for storing the queue's state in the group metadata.
-	 */
-	String QUEUE_STATE_KEY = "queueState";
-
-	/**
-	 * Sends a message using the given queue.
-	 */
-	QueueMessage sendMessage(Transaction txn, Group queue, long timestamp,
-			byte[] body, Metadata meta) throws DbException;
-
-	/**
-	 * Sets the message validator for the given client.
-	 */
-	void registerMessageValidator(ClientId c, QueueMessageValidator v);
-
-	/**
-	 * Sets the incoming message hook for the given client. The hook will be
-	 * called once for each incoming message that passes validation. Messages
-	 * are passed to the hook in order.
-	 */
-	void registerIncomingMessageHook(ClientId c, IncomingQueueMessageHook hook);
-
-	@Deprecated
-	interface QueueMessageValidator {
-
-		/**
-		 * Validates the given message and returns its metadata and
-		 * dependencies.
-		 */
-		MessageContext validateMessage(QueueMessage q, Group g)
-				throws InvalidMessageException;
-	}
-
-	@Deprecated
-	interface IncomingQueueMessageHook {
-
-		/**
-		 * Called once for each incoming message that passes validation.
-		 * Messages are passed to the hook in order.
-		 *
-		 * @throws DbException Should only be used for real database errors.
-		 * If this is thrown, delivery will be attempted again at next startup,
-		 * whereas if an InvalidMessageException is thrown,
-		 * the message will be permanently invalidated.
-		 * @throws InvalidMessageException for any non-database error
-		 * that occurs while handling remotely created data.
-		 * This includes errors that occur while handling locally created data
-		 * in a context controlled by remotely created data
-		 * (for example, parsing the metadata of a dependency
-		 * of an incoming message).
-		 * Never rethrow DbException as InvalidMessageException!
-		 */
-		void incomingMessage(Transaction txn, QueueMessage q, Metadata meta)
-				throws DbException, InvalidMessageException;
-	}
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/client/ProtocolEngine.java b/briar-api/src/main/java/org/briarproject/briar/api/client/ProtocolEngine.java
deleted file mode 100644
index 281d9af86ff4b2d642a08d0854860cf46a626db3..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/client/ProtocolEngine.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package org.briarproject.briar.api.client;
-
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-
-import java.util.List;
-
-@Deprecated
-@NotNullByDefault
-public interface ProtocolEngine<A, S, M> {
-
-	StateUpdate<S, M> onLocalAction(S localState, A action);
-
-	StateUpdate<S, M> onMessageReceived(S localState, M received);
-
-	StateUpdate<S, M> onMessageDelivered(S localState, M delivered);
-
-	class StateUpdate<S, M> {
-		public final boolean deleteMessage;
-		public final boolean deleteState;
-		public final S localState;
-		public final List<M> toSend;
-		public final List<Event> toBroadcast;
-
-		/**
-		 * This class represents an update of the local protocol state.
-		 * It only shows how the state should be updated,
-		 * but does not carry out the updates on its own.
-		 *
-		 * @param deleteMessage whether to delete the message that triggered
-		 * the state update. This will be ignored for
-		 * {@link ProtocolEngine#onLocalAction}.
-		 * @param deleteState whether to delete the localState {@link S}
-		 * @param localState the new local state
-		 * @param toSend a list of messages to be sent as part of the
-		 * state update
-		 * @param toBroadcast a list of events to broadcast as result of the
-		 * state update
-		 */
-		public StateUpdate(boolean deleteMessage, boolean deleteState,
-				S localState, List<M> toSend, List<Event> toBroadcast) {
-
-			this.deleteMessage = deleteMessage;
-			this.deleteState = deleteState;
-			this.localState = localState;
-			this.toSend = toSend;
-			this.toBroadcast = toBroadcast;
-		}
-	}
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/client/QueueMessage.java b/briar-api/src/main/java/org/briarproject/briar/api/client/QueueMessage.java
deleted file mode 100644
index c41b6da2b665dfc2dbc5b0c939c81f7917c80543..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/client/QueueMessage.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package org.briarproject.briar.api.client;
-
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageId;
-
-import static org.briarproject.bramble.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-
-@Deprecated
-@NotNullByDefault
-public class QueueMessage extends Message {
-
-	public static final int QUEUE_MESSAGE_HEADER_LENGTH =
-			MESSAGE_HEADER_LENGTH + 8;
-	public static final int MAX_QUEUE_MESSAGE_BODY_LENGTH =
-			MAX_MESSAGE_BODY_LENGTH - 8;
-
-	private final long queuePosition;
-
-	public QueueMessage(MessageId id, GroupId groupId, long timestamp,
-			long queuePosition, byte[] raw) {
-		super(id, groupId, timestamp, raw);
-		this.queuePosition = queuePosition;
-	}
-
-	public long getQueuePosition() {
-		return queuePosition;
-	}
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/client/QueueMessageFactory.java b/briar-api/src/main/java/org/briarproject/briar/api/client/QueueMessageFactory.java
deleted file mode 100644
index ac458a8a89494536aab35ba09fffc68e0a80b182..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/client/QueueMessageFactory.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.briarproject.briar.api.client;
-
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.MessageId;
-
-@Deprecated
-@NotNullByDefault
-public interface QueueMessageFactory {
-
-	QueueMessage createMessage(GroupId groupId, long timestamp,
-			long queuePosition, byte[] body);
-
-	QueueMessage createMessage(MessageId id, byte[] raw);
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroduceeAction.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroduceeAction.java
deleted file mode 100644
index 68881b9dc0d57e5993afb38e35943652141629a5..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroduceeAction.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package org.briarproject.briar.api.introduction;
-
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-
-import javax.annotation.Nullable;
-
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-
-@NotNullByDefault
-public enum IntroduceeAction {
-
-	LOCAL_ACCEPT,
-	LOCAL_DECLINE,
-	LOCAL_ABORT,
-	REMOTE_REQUEST,
-	REMOTE_ACCEPT,
-	REMOTE_DECLINE,
-	REMOTE_ABORT,
-	ACK;
-
-	@Nullable
-	public static IntroduceeAction getRemote(int type, boolean accept) {
-		if (type == TYPE_REQUEST) return REMOTE_REQUEST;
-		if (type == TYPE_RESPONSE && accept) return REMOTE_ACCEPT;
-		if (type == TYPE_RESPONSE) return REMOTE_DECLINE;
-		if (type == TYPE_ACK) return ACK;
-		if (type == TYPE_ABORT) return REMOTE_ABORT;
-		return null;
-	}
-
-	@Nullable
-	public static IntroduceeAction getRemote(int type) {
-		return getRemote(type, true);
-	}
-
-	@Nullable
-	public static IntroduceeAction getLocal(int type, boolean accept) {
-		if (type == TYPE_RESPONSE && accept) return LOCAL_ACCEPT;
-		if (type == TYPE_RESPONSE) return LOCAL_DECLINE;
-		if (type == TYPE_ACK) return ACK;
-		if (type == TYPE_ABORT) return LOCAL_ABORT;
-		return null;
-	}
-
-	@Nullable
-	public static IntroduceeAction getLocal(int type) {
-		return getLocal(type, true);
-	}
-
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroduceeProtocolState.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroduceeProtocolState.java
deleted file mode 100644
index e696181a01715261bd2d58b697ce03ff61907d67..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroduceeProtocolState.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package org.briarproject.briar.api.introduction;
-
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-
-import javax.annotation.concurrent.Immutable;
-
-import static org.briarproject.briar.api.introduction.IntroduceeAction.ACK;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.LOCAL_ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.LOCAL_DECLINE;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.REMOTE_ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.REMOTE_DECLINE;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.REMOTE_REQUEST;
-
-@Immutable
-@NotNullByDefault
-public enum IntroduceeProtocolState {
-
-	ERROR(0),
-	AWAIT_REQUEST(1) {
-		@Override
-		public IntroduceeProtocolState next(IntroduceeAction a) {
-			if (a == REMOTE_REQUEST) return AWAIT_RESPONSES;
-			return ERROR;
-		}
-	},
-	AWAIT_RESPONSES(2) {
-		@Override
-		public IntroduceeProtocolState next(IntroduceeAction a) {
-			if (a == REMOTE_ACCEPT) return AWAIT_LOCAL_RESPONSE;
-			if (a == REMOTE_DECLINE) return FINISHED;
-			if (a == LOCAL_ACCEPT) return AWAIT_REMOTE_RESPONSE;
-			if (a == LOCAL_DECLINE) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_REMOTE_RESPONSE(3) {
-		@Override
-		public IntroduceeProtocolState next(IntroduceeAction a) {
-			if (a == REMOTE_ACCEPT) return AWAIT_ACK;
-			if (a == REMOTE_DECLINE) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_LOCAL_RESPONSE(4) {
-		@Override
-		public IntroduceeProtocolState next(IntroduceeAction a) {
-			if (a == LOCAL_ACCEPT) return AWAIT_ACK;
-			if (a == LOCAL_DECLINE) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_ACK(5) {
-		@Override
-		public IntroduceeProtocolState next(IntroduceeAction a) {
-			if (a == ACK) return FINISHED;
-			return ERROR;
-		}
-	},
-	FINISHED(6);
-
-	private final int value;
-
-	IntroduceeProtocolState(int value) {
-		this.value = value;
-	}
-
-	public int getValue() {
-		return value;
-	}
-
-	public static IntroduceeProtocolState fromValue(int value) {
-		for (IntroduceeProtocolState s : values()) {
-			if (s.value == value) return s;
-		}
-		throw new IllegalArgumentException();
-	}
-
-	public IntroduceeProtocolState next(IntroduceeAction a) {
-		return this;
-	}
-
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroducerAction.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroducerAction.java
deleted file mode 100644
index 7123c7eb26f8abf6f9c455307253aaaf04f59c75..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroducerAction.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package org.briarproject.briar.api.introduction;
-
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-
-import javax.annotation.Nullable;
-
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-
-@NotNullByDefault
-public enum IntroducerAction {
-
-	LOCAL_REQUEST,
-	LOCAL_ABORT,
-	REMOTE_ACCEPT_1,
-	REMOTE_ACCEPT_2,
-	REMOTE_DECLINE_1,
-	REMOTE_DECLINE_2,
-	REMOTE_ABORT,
-	ACK_1,
-	ACK_2;
-
-	@Nullable
-	public static IntroducerAction getLocal(int type) {
-		if (type == TYPE_REQUEST) return LOCAL_REQUEST;
-		if (type == TYPE_ABORT) return LOCAL_ABORT;
-		return null;
-	}
-
-	@Nullable
-	public static IntroducerAction getRemote(int type, boolean one,
-			boolean accept) {
-
-		if (one) {
-			if (type == TYPE_RESPONSE && accept) return REMOTE_ACCEPT_1;
-			if (type == TYPE_RESPONSE) return REMOTE_DECLINE_1;
-			if (type == TYPE_ACK) return ACK_1;
-		} else {
-			if (type == TYPE_RESPONSE && accept) return REMOTE_ACCEPT_2;
-			if (type == TYPE_RESPONSE) return REMOTE_DECLINE_2;
-			if (type == TYPE_ACK) return ACK_2;
-		}
-		if (type == TYPE_ABORT) return REMOTE_ABORT;
-		return null;
-	}
-
-	@Nullable
-	public static IntroducerAction getRemote(int type, boolean one) {
-		return getRemote(type, one, true);
-	}
-
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroducerProtocolState.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroducerProtocolState.java
deleted file mode 100644
index b3d89864e421b60853111c67e43fdbe9a2c6a6f3..0000000000000000000000000000000000000000
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroducerProtocolState.java
+++ /dev/null
@@ -1,108 +0,0 @@
-package org.briarproject.briar.api.introduction;
-
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-
-import javax.annotation.concurrent.Immutable;
-
-import static org.briarproject.briar.api.introduction.IntroducerAction.ACK_1;
-import static org.briarproject.briar.api.introduction.IntroducerAction.ACK_2;
-import static org.briarproject.briar.api.introduction.IntroducerAction.LOCAL_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_ACCEPT_1;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_ACCEPT_2;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_DECLINE_1;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_DECLINE_2;
-
-@Immutable
-@NotNullByDefault
-public enum IntroducerProtocolState {
-
-	ERROR(0),
-	PREPARE_REQUESTS(1) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == LOCAL_REQUEST) return AWAIT_RESPONSES;
-			return ERROR;
-		}
-	},
-	AWAIT_RESPONSES(2) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == REMOTE_ACCEPT_1) return AWAIT_RESPONSE_2;
-			if (a == REMOTE_ACCEPT_2) return AWAIT_RESPONSE_1;
-			if (a == REMOTE_DECLINE_1) return FINISHED;
-			if (a == REMOTE_DECLINE_2) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_RESPONSE_1(3) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == REMOTE_ACCEPT_1) return AWAIT_ACKS;
-			if (a == REMOTE_DECLINE_1) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_RESPONSE_2(4) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == REMOTE_ACCEPT_2) return AWAIT_ACKS;
-			if (a == REMOTE_DECLINE_2) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_ACKS(5) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == ACK_1) return AWAIT_ACK_2;
-			if (a == ACK_2) return AWAIT_ACK_1;
-			return ERROR;
-		}
-	},
-	AWAIT_ACK_1(6) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == ACK_1) return FINISHED;
-			return ERROR;
-		}
-	},
-	AWAIT_ACK_2(7) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == ACK_2) return FINISHED;
-			return ERROR;
-		}
-	},
-	FINISHED(8) {
-		@Override
-		public IntroducerProtocolState next(IntroducerAction a) {
-			if (a == REMOTE_ABORT) return ERROR;
-			return FINISHED;
-		}
-	};
-
-	private final int value;
-
-	IntroducerProtocolState(int value) {
-		this.value = value;
-	}
-
-	public int getValue() {
-		return value;
-	}
-
-	public static IntroducerProtocolState fromValue(int value) {
-		for (IntroducerProtocolState s : values()) {
-			if (s.value == value) return s;
-		}
-		throw new IllegalArgumentException();
-	}
-
-	public static boolean isOngoing(IntroducerProtocolState state) {
-		return state != FINISHED && state != ERROR;
-	}
-
-	public IntroducerProtocolState next(IntroducerAction a) {
-		return this;
-	}
-}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionConstants.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionConstants.java
index 72e6030b10b207a179abf99be29e7897af2777cb..87681d525076125d90ed43e7437911d4aea8f97e 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionConstants.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionConstants.java
@@ -4,126 +4,29 @@ import static org.briarproject.bramble.api.sync.SyncConstants.MAX_MESSAGE_BODY_L
 
 public interface IntroductionConstants {
 
-	/* Protocol roles */
-	int ROLE_INTRODUCER = 0;
-	int ROLE_INTRODUCEE = 1;
-
-	/* Message types */
-	int TYPE_REQUEST = 1;
-	int TYPE_RESPONSE = 2;
-	int TYPE_ACK = 3;
-	int TYPE_ABORT = 4;
-
-	/* Message Constants */
-	String TYPE = "type";
-	String GROUP_ID = "groupId";
-	String SESSION_ID = "sessionId";
-	String CONTACT = "contactId";
-	String NAME = "name";
-	String PUBLIC_KEY = "publicKey";
-	String E_PUBLIC_KEY = "ephemeralPublicKey";
-	String MSG = "msg";
-	String ACCEPT = "accept";
-	String TIME = "time";
-	String TRANSPORT = "transport";
-	String MESSAGE_ID = "messageId";
-	String MESSAGE_TIME = "timestamp";
-	String MAC = "mac";
-	String SIGNATURE = "signature";
-
-	/* Validation Constants */
-
-	/**
-	 * The length of the message authentication code in bytes.
-	 */
-	int MAC_LENGTH = 32;
-
 	/**
 	 * The maximum length of the introducer's optional message to the
 	 * introducees in UTF-8 bytes.
 	 */
-	int MAX_INTRODUCTION_MESSAGE_LENGTH = MAX_MESSAGE_BODY_LENGTH - 1024;
+	int MAX_REQUEST_MESSAGE_LENGTH = MAX_MESSAGE_BODY_LENGTH - 1024;
 
-	/* Introducer Local State Metadata */
-	String STATE = "state";
-	String ROLE = "role";
-	String GROUP_ID_1 = "groupId1";
-	String GROUP_ID_2 = "groupId2";
-	String CONTACT_1 = "contact1";
-	String CONTACT_2 = "contact2";
-	String AUTHOR_ID_1 = "authorId1";
-	String AUTHOR_ID_2 = "authorId2";
-	String CONTACT_ID_1 = "contactId1";
-	String CONTACT_ID_2 = "contactId2";
-	String RESPONSE_1 = "response1";
-	String RESPONSE_2 = "response2";
+	String LABEL_SESSION_ID = "org.briarproject.briar.introduction/SESSION_ID";
 
-	/* Introduction Request Action */
-	String PUBLIC_KEY1 = "publicKey1";
-	String PUBLIC_KEY2 = "publicKey2";
+	String LABEL_MASTER_KEY = "org.briarproject.briar.introduction/MASTER_KEY";
 
-	/* Introducee Local State Metadata (without those already defined) */
-	String STORAGE_ID = "storageId";
-	String INTRODUCER = "introducer";
-	String LOCAL_AUTHOR_ID = "localAuthorId";
-	String REMOTE_AUTHOR_ID = "remoteAuthorId";
-	String OUR_PUBLIC_KEY = "ourEphemeralPublicKey";
-	String OUR_PRIVATE_KEY = "ourEphemeralPrivateKey";
-	String OUR_TIME = "ourTime";
-	String ADDED_CONTACT_ID = "addedContactId";
-	String NOT_OUR_RESPONSE = "notOurResponse";
-	String EXISTS = "contactExists";
-	String REMOTE_AUTHOR_IS_US = "remoteAuthorIsUs";
-	String ANSWERED = "answered";
-	String NONCE = "nonce";
-	String MAC_KEY = "macKey";
-	String OUR_TRANSPORT = "ourTransport";
-	String OUR_MAC = "ourMac";
-	String OUR_SIGNATURE = "ourSignature";
-
-	String TASK = "task";
-	int TASK_ADD_CONTACT = 0;
-	int TASK_ACTIVATE_CONTACT = 1;
-	int TASK_ABORT = 2;
-
-	/**
-	 * Label for deriving the shared secret.
-	 */
-	String SHARED_SECRET_LABEL =
-			"org.briarproject.briar.introduction/SHARED_SECRET";
+	String LABEL_ALICE_MAC_KEY =
+			"org.briarproject.briar.introduction/ALICE_MAC_KEY";
 
-	/**
-	 * Label for deriving Alice's key binding nonce from the shared secret.
-	 */
-	String ALICE_NONCE_LABEL =
-			"org.briarproject.briar.introduction/ALICE_NONCE";
+	String LABEL_BOB_MAC_KEY =
+			"org.briarproject.briar.introduction/BOB_MAC_KEY";
 
-	/**
-	 * Label for deriving Bob's key binding nonce from the shared secret.
-	 */
-	String BOB_NONCE_LABEL =
-			"org.briarproject.briar.introduction/BOB_NONCE";
+	String LABEL_AUTH_MAC = "org.briarproject.briar.introduction/AUTH_MAC";
 
-	/**
-	 * Label for deriving Alice's MAC key from the shared secret.
-	 */
-	String ALICE_MAC_KEY_LABEL =
-			"org.briarproject.briar.introduction/ALICE_MAC_KEY";
+	String LABEL_AUTH_SIGN = "org.briarproject.briar.introduction/AUTH_SIGN";
 
-	/**
-	 * Label for deriving Bob's MAC key from the shared secret.
-	 */
-	String BOB_MAC_KEY_LABEL =
-			"org.briarproject.briar.introduction/BOB_MAC_KEY";
+	String LABEL_AUTH_NONCE = "org.briarproject.briar.introduction/AUTH_NONCE";
 
-	/**
-	 * Label for signing the introduction response.
-	 */
-	String SIGNING_LABEL =
-			"org.briarproject.briar.introduction/RESPONSE_SIGNATURE";
+	String LABEL_ACTIVATE_MAC =
+			"org.briarproject.briar.introduction/ACTIVATE_MAC";
 
-	/**
-	 * Label for MACing the introduction response.
-	 */
-	String MAC_LABEL = "org.briarproject.briar.introduction/RESPONSE_MAC";
 }
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionManager.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionManager.java
index ce9d067277fabd9aed94a254f3c86f2b47ee3775..8711193f15b9e4696fa0bebb8bb2d8f61d9aeb16 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionManager.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionManager.java
@@ -1,6 +1,5 @@
 package org.briarproject.briar.api.introduction;
 
-import org.briarproject.bramble.api.FormatException;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.db.DbException;
@@ -24,25 +23,21 @@ public interface IntroductionManager extends ConversationClient {
 	/**
 	 * The current version of the introduction client.
 	 */
-	int CLIENT_VERSION = 0;
+	int CLIENT_VERSION = 1;
+
+	boolean canIntroduce(Contact c1, Contact c2) throws DbException;
 
 	/**
 	 * Sends two initial introduction messages.
 	 */
 	void makeIntroduction(Contact c1, Contact c2, @Nullable String msg,
-			long timestamp) throws DbException, FormatException;
-
-	/**
-	 * Accepts an introduction.
-	 */
-	void acceptIntroduction(ContactId contactId, SessionId sessionId,
-			long timestamp) throws DbException, FormatException;
+			long timestamp) throws DbException;
 
 	/**
-	 * Declines an introduction.
+	 * Responds to an introduction.
 	 */
-	void declineIntroduction(ContactId contactId, SessionId sessionId,
-			long timestamp) throws DbException, FormatException;
+	void respondToIntroduction(ContactId contactId, SessionId sessionId,
+			long timestamp, boolean accept) throws DbException;
 
 	/**
 	 * Returns all introduction messages for the given contact.
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionMessage.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionMessage.java
index 861469a3a4d0c8eb6b9f34b5c6e5247db98fe995..009487fa2faf852fc5aa843db4d3aba9a09ba24f 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionMessage.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionMessage.java
@@ -8,7 +8,7 @@ import org.briarproject.briar.api.client.SessionId;
 
 import javax.annotation.concurrent.Immutable;
 
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCER;
 
 @Immutable
 @NotNullByDefault
@@ -16,10 +16,10 @@ public class IntroductionMessage extends BaseMessageHeader {
 
 	private final SessionId sessionId;
 	private final MessageId messageId;
-	private final int role;
+	private final Role role;
 
 	IntroductionMessage(SessionId sessionId, MessageId messageId,
-			GroupId groupId, int role, long time, boolean local, boolean sent,
+			GroupId groupId, Role role, long time, boolean local, boolean sent,
 			boolean seen, boolean read) {
 
 		super(messageId, groupId, time, local, sent, seen, read);
@@ -37,7 +37,7 @@ public class IntroductionMessage extends BaseMessageHeader {
 	}
 
 	public boolean isIntroducer() {
-		return role == ROLE_INTRODUCER;
+		return role == INTRODUCER;
 	}
 
 }
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionRequest.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionRequest.java
index 428528260c6a4f11dbd88983e78f55183f9b1614..b2a804bd88b8de918546c68e15a3327e4605180f 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionRequest.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionRequest.java
@@ -1,6 +1,5 @@
 package org.briarproject.briar.api.introduction;
 
-import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.sync.GroupId;
 import org.briarproject.bramble.api.sync.MessageId;
@@ -15,21 +14,19 @@ public class IntroductionRequest extends IntroductionResponse {
 
 	@Nullable
 	private final String message;
-	private final boolean answered, exists, introducesOtherIdentity;
+	private final boolean answered, exists;
 
 	public IntroductionRequest(SessionId sessionId, MessageId messageId,
-			GroupId groupId, int role, long time, boolean local, boolean sent,
-			boolean seen, boolean read, AuthorId authorId, String name,
-			boolean accepted, @Nullable String message, boolean answered,
-			boolean exists, boolean introducesOtherIdentity) {
+			GroupId groupId, Role role, long time, boolean local, boolean sent,
+			boolean seen, boolean read, String name, boolean accepted,
+			@Nullable String message, boolean answered, boolean exists) {
 
 		super(sessionId, messageId, groupId, role, time, local, sent, seen,
-				read, authorId, name, accepted);
+				read, name, accepted);
 
 		this.message = message;
 		this.answered = answered;
 		this.exists = exists;
-		this.introducesOtherIdentity = introducesOtherIdentity;
 	}
 
 	@Nullable
@@ -44,8 +41,4 @@ public class IntroductionRequest extends IntroductionResponse {
 	public boolean contactExists() {
 		return exists;
 	}
-
-	public boolean doesIntroduceOtherIdentity() {
-		return introducesOtherIdentity;
-	}
 }
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionResponse.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionResponse.java
index 22df6eba89e2e44d38fb15cdd94bd75dfacd3cb4..816135d43f59668a4ecfa4baf27e625ba2469462 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionResponse.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/IntroductionResponse.java
@@ -1,6 +1,5 @@
 package org.briarproject.briar.api.introduction;
 
-import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.sync.GroupId;
 import org.briarproject.bramble.api.sync.MessageId;
@@ -12,19 +11,15 @@ import javax.annotation.concurrent.Immutable;
 @NotNullByDefault
 public class IntroductionResponse extends IntroductionMessage {
 
-	private final AuthorId remoteAuthorId;
 	private final String name;
 	private final boolean accepted;
 
 	public IntroductionResponse(SessionId sessionId, MessageId messageId,
-			GroupId groupId, int role, long time, boolean local, boolean sent,
-			boolean seen, boolean read, AuthorId remoteAuthorId, String name,
-			boolean accepted) {
-
+			GroupId groupId, Role role, long time, boolean local, boolean sent,
+			boolean seen, boolean read, String name, boolean accepted) {
 		super(sessionId, messageId, groupId, role, time, local, sent, seen,
 				read);
 
-		this.remoteAuthorId = remoteAuthorId;
 		this.name = name;
 		this.accepted = accepted;
 	}
@@ -37,7 +32,4 @@ public class IntroductionResponse extends IntroductionMessage {
 		return accepted;
 	}
 
-	public AuthorId getRemoteAuthorId() {
-		return remoteAuthorId;
-	}
 }
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/Role.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/Role.java
new file mode 100644
index 0000000000000000000000000000000000000000..38f0bd44cf85fb804fcff9f49e520474423e4a19
--- /dev/null
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/Role.java
@@ -0,0 +1,29 @@
+package org.briarproject.briar.api.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+public enum Role {
+
+	INTRODUCER(0), INTRODUCEE(1);
+
+	private final int value;
+
+	Role(int value) {
+		this.value = value;
+	}
+
+	public int getValue() {
+		return value;
+	}
+
+	public static Role fromValue(int value) throws FormatException {
+		for (Role r : values()) if (r.value == value) return r;
+		throw new FormatException();
+	}
+
+}
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/introduction/event/IntroductionAbortedEvent.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction/event/IntroductionAbortedEvent.java
index 5855275918a6001dbabf6211183d5aad40d4b9a5..113ba400efdebb484b0b41b84d70ccc64443f2b3 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/introduction/event/IntroductionAbortedEvent.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction/event/IntroductionAbortedEvent.java
@@ -1,6 +1,5 @@
 package org.briarproject.briar.api.introduction.event;
 
-import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.event.Event;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.briar.api.client.SessionId;
@@ -11,19 +10,14 @@ import javax.annotation.concurrent.Immutable;
 @NotNullByDefault
 public class IntroductionAbortedEvent extends Event {
 
-	private final ContactId contactId;
 	private final SessionId sessionId;
 
-	public IntroductionAbortedEvent(ContactId contactId, SessionId sessionId) {
-		this.contactId = contactId;
+	public IntroductionAbortedEvent(SessionId sessionId) {
 		this.sessionId = sessionId;
 	}
 
-	public ContactId getContactId() {
-		return contactId;
-	}
-
 	public SessionId getSessionId() {
 		return sessionId;
 	}
+
 }
diff --git a/briar-core/src/main/java/org/briarproject/briar/client/BdfIncomingMessageHook.java b/briar-core/src/main/java/org/briarproject/briar/client/BdfIncomingMessageHook.java
index 19d8142c1a6c2f8e99d378ffea4a8f769e5d3cc3..edc62948dd5681e84bd72f1834b6158f31833d30 100644
--- a/briar-core/src/main/java/org/briarproject/briar/client/BdfIncomingMessageHook.java
+++ b/briar-core/src/main/java/org/briarproject/briar/client/BdfIncomingMessageHook.java
@@ -13,18 +13,12 @@ import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.sync.InvalidMessageException;
 import org.briarproject.bramble.api.sync.Message;
 import org.briarproject.bramble.api.sync.ValidationManager.IncomingMessageHook;
-import org.briarproject.briar.api.client.MessageQueueManager.IncomingQueueMessageHook;
-import org.briarproject.briar.api.client.QueueMessage;
 
 import javax.annotation.concurrent.Immutable;
 
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-import static org.briarproject.briar.api.client.QueueMessage.QUEUE_MESSAGE_HEADER_LENGTH;
-
 @Immutable
 @NotNullByDefault
-public abstract class BdfIncomingMessageHook implements IncomingMessageHook,
-		IncomingQueueMessageHook {
+public abstract class BdfIncomingMessageHook implements IncomingMessageHook {
 
 	protected final DatabaseComponent db;
 	protected final ClientHelper clientHelper;
@@ -40,6 +34,7 @@ public abstract class BdfIncomingMessageHook implements IncomingMessageHook,
 	/**
 	 * Called once for each incoming message that passes validation.
 	 *
+	 * @return whether or not this message should be shared
 	 * @throws DbException Should only be used for real database errors.
 	 * If this is thrown, delivery will be attempted again at next startup,
 	 * whereas if a FormatException is thrown, the message will be permanently
@@ -60,29 +55,12 @@ public abstract class BdfIncomingMessageHook implements IncomingMessageHook,
 	public boolean incomingMessage(Transaction txn, Message m, Metadata meta)
 			throws DbException, InvalidMessageException {
 		try {
-			return incomingMessage(txn, m, meta, MESSAGE_HEADER_LENGTH);
+			BdfList body = clientHelper.toList(m);
+			BdfDictionary metaDictionary = metadataParser.parse(meta);
+			return incomingMessage(txn, m, body, metaDictionary);
 		} catch (FormatException e) {
 			throw new InvalidMessageException(e);
 		}
 	}
 
-	@Override
-	public void incomingMessage(Transaction txn, QueueMessage q, Metadata meta)
-			throws DbException, InvalidMessageException {
-		try {
-			incomingMessage(txn, q, meta, QUEUE_MESSAGE_HEADER_LENGTH);
-		} catch (FormatException e) {
-			throw new InvalidMessageException(e);
-		}
-	}
-
-	private boolean incomingMessage(Transaction txn, Message m, Metadata meta,
-			int headerLength) throws DbException, FormatException {
-		byte[] raw = m.getRaw();
-		BdfList body = clientHelper.toList(raw, headerLength,
-				raw.length - headerLength);
-		BdfDictionary metaDictionary = metadataParser.parse(meta);
-		return incomingMessage(txn, m, body, metaDictionary);
-	}
-
 }
diff --git a/briar-core/src/main/java/org/briarproject/briar/client/BdfQueueMessageValidator.java b/briar-core/src/main/java/org/briarproject/briar/client/BdfQueueMessageValidator.java
deleted file mode 100644
index 48fd665a10f17cd77c2fc446da96ccf5a39d3181..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/client/BdfQueueMessageValidator.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package org.briarproject.briar.client;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.BdfMessageContext;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.data.MetadataEncoder;
-import org.briarproject.bramble.api.db.Metadata;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.InvalidMessageException;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageContext;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.api.client.MessageQueueManager.QueueMessageValidator;
-import org.briarproject.briar.api.client.QueueMessage;
-
-import java.util.logging.Logger;
-
-import javax.annotation.concurrent.Immutable;
-
-import static org.briarproject.bramble.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
-import static org.briarproject.briar.api.client.QueueMessage.QUEUE_MESSAGE_HEADER_LENGTH;
-
-@Deprecated
-@Immutable
-@NotNullByDefault
-public abstract class BdfQueueMessageValidator
-		implements QueueMessageValidator {
-
-	protected static final Logger LOG =
-			Logger.getLogger(BdfQueueMessageValidator.class.getName());
-
-	protected final ClientHelper clientHelper;
-	protected final MetadataEncoder metadataEncoder;
-	protected final Clock clock;
-
-	protected BdfQueueMessageValidator(ClientHelper clientHelper,
-			MetadataEncoder metadataEncoder, Clock clock) {
-		this.clientHelper = clientHelper;
-		this.metadataEncoder = metadataEncoder;
-		this.clock = clock;
-	}
-
-	protected abstract BdfMessageContext validateMessage(Message m, Group g,
-			BdfList body) throws InvalidMessageException, FormatException;
-
-	@Override
-	public MessageContext validateMessage(QueueMessage q, Group g)
-			throws InvalidMessageException {
-		// Reject the message if it's too far in the future
-		long now = clock.currentTimeMillis();
-		if (q.getTimestamp() - now > MAX_CLOCK_DIFFERENCE) {
-			throw new InvalidMessageException(
-					"Timestamp is too far in the future");
-		}
-		byte[] raw = q.getRaw();
-		if (raw.length <= QUEUE_MESSAGE_HEADER_LENGTH) {
-			throw new InvalidMessageException("Message is too short");
-		}
-		try {
-			BdfList body = clientHelper.toList(raw, QUEUE_MESSAGE_HEADER_LENGTH,
-					raw.length - QUEUE_MESSAGE_HEADER_LENGTH);
-			BdfMessageContext result = validateMessage(q, g, body);
-			Metadata meta = metadataEncoder.encode(result.getDictionary());
-			return new MessageContext(meta, result.getDependencies());
-		} catch (FormatException e) {
-			throw new InvalidMessageException(e);
-		}
-	}
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/client/BriarClientModule.java b/briar-core/src/main/java/org/briarproject/briar/client/BriarClientModule.java
index 46ee505dfa0cf6ad68f119ef8563a024b16f9f42..9eeb6b9597eac924138444067dcddd716384be8c 100644
--- a/briar-core/src/main/java/org/briarproject/briar/client/BriarClientModule.java
+++ b/briar-core/src/main/java/org/briarproject/briar/client/BriarClientModule.java
@@ -1,14 +1,6 @@
 package org.briarproject.briar.client;
 
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.sync.MessageFactory;
-import org.briarproject.bramble.api.sync.ValidationManager;
-import org.briarproject.briar.api.client.MessageQueueManager;
 import org.briarproject.briar.api.client.MessageTracker;
-import org.briarproject.briar.api.client.QueueMessageFactory;
-
-import javax.inject.Singleton;
 
 import dagger.Module;
 import dagger.Provides;
@@ -16,21 +8,6 @@ import dagger.Provides;
 @Module
 public class BriarClientModule {
 
-	@Provides
-	@Singleton
-	MessageQueueManager provideMessageQueueManager(DatabaseComponent db,
-			ClientHelper clientHelper, QueueMessageFactory queueMessageFactory,
-			ValidationManager validationManager) {
-		return new MessageQueueManagerImpl(db, clientHelper,
-				queueMessageFactory, validationManager);
-	}
-
-	@Provides
-	QueueMessageFactory provideQueueMessageFactory(
-			MessageFactory messageFactory) {
-		return new QueueMessageFactoryImpl(messageFactory);
-	}
-
 	@Provides
 	MessageTracker provideMessageTracker(MessageTrackerImpl messageTracker) {
 		return messageTracker;
diff --git a/briar-core/src/main/java/org/briarproject/briar/client/MessageQueueManagerImpl.java b/briar-core/src/main/java/org/briarproject/briar/client/MessageQueueManagerImpl.java
deleted file mode 100644
index 47c91bbc2d7edc24ae4b9945a17faed057d0ada3..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/client/MessageQueueManagerImpl.java
+++ /dev/null
@@ -1,259 +0,0 @@
-package org.briarproject.briar.client;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Metadata;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.ClientId;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.InvalidMessageException;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageContext;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.sync.ValidationManager;
-import org.briarproject.bramble.api.sync.ValidationManager.IncomingMessageHook;
-import org.briarproject.bramble.api.sync.ValidationManager.MessageValidator;
-import org.briarproject.bramble.util.ByteUtils;
-import org.briarproject.briar.api.client.MessageQueueManager;
-import org.briarproject.briar.api.client.QueueMessage;
-import org.briarproject.briar.api.client.QueueMessageFactory;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map.Entry;
-import java.util.TreeMap;
-import java.util.logging.Logger;
-
-import javax.annotation.Nullable;
-import javax.annotation.concurrent.Immutable;
-import javax.inject.Inject;
-
-import static java.util.logging.Level.INFO;
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-import static org.briarproject.briar.api.client.QueueMessage.QUEUE_MESSAGE_HEADER_LENGTH;
-
-@Immutable
-@NotNullByDefault
-class MessageQueueManagerImpl implements MessageQueueManager {
-
-	private static final String OUTGOING_POSITION_KEY = "nextOut";
-	private static final String INCOMING_POSITION_KEY = "nextIn";
-	private static final String PENDING_MESSAGES_KEY = "pending";
-
-	private static final Logger LOG =
-			Logger.getLogger(MessageQueueManagerImpl.class.getName());
-
-	private final DatabaseComponent db;
-	private final ClientHelper clientHelper;
-	private final QueueMessageFactory queueMessageFactory;
-	private final ValidationManager validationManager;
-
-	@Inject
-	MessageQueueManagerImpl(DatabaseComponent db, ClientHelper clientHelper,
-			QueueMessageFactory queueMessageFactory,
-			ValidationManager validationManager) {
-		this.db = db;
-		this.clientHelper = clientHelper;
-		this.queueMessageFactory = queueMessageFactory;
-		this.validationManager = validationManager;
-	}
-
-	@Override
-	public QueueMessage sendMessage(Transaction txn, Group queue,
-			long timestamp, byte[] body, Metadata meta) throws DbException {
-		QueueState queueState = loadQueueState(txn, queue.getId());
-		long queuePosition = queueState.outgoingPosition;
-		queueState.outgoingPosition++;
-		if (LOG.isLoggable(INFO))
-			LOG.info("Sending message with position " + queuePosition);
-		saveQueueState(txn, queue.getId(), queueState);
-		QueueMessage q = queueMessageFactory.createMessage(queue.getId(),
-				timestamp, queuePosition, body);
-		db.addLocalMessage(txn, q, meta, true);
-		return q;
-	}
-
-	@Override
-	public void registerMessageValidator(ClientId c, QueueMessageValidator v) {
-		validationManager.registerMessageValidator(c,
-				new DelegatingMessageValidator(v));
-	}
-
-	@Override
-	public void registerIncomingMessageHook(ClientId c,
-			IncomingQueueMessageHook hook) {
-		validationManager.registerIncomingMessageHook(c,
-				new DelegatingIncomingMessageHook(hook));
-	}
-
-	private QueueState loadQueueState(Transaction txn, GroupId g)
-			throws DbException {
-		try {
-			TreeMap<Long, MessageId> pending = new TreeMap<>();
-			Metadata groupMeta = db.getGroupMetadata(txn, g);
-			byte[] raw = groupMeta.get(QUEUE_STATE_KEY);
-			if (raw == null) return new QueueState(0, 0, pending);
-			BdfDictionary d = clientHelper.toDictionary(raw, 0, raw.length);
-			long outgoingPosition = d.getLong(OUTGOING_POSITION_KEY);
-			long incomingPosition = d.getLong(INCOMING_POSITION_KEY);
-			BdfList pendingList = d.getList(PENDING_MESSAGES_KEY);
-			for (int i = 0; i < pendingList.size(); i++) {
-				BdfList item = pendingList.getList(i);
-				if (item.size() != 2) throw new FormatException();
-				pending.put(item.getLong(0), new MessageId(item.getRaw(1)));
-			}
-			return new QueueState(outgoingPosition, incomingPosition, pending);
-		} catch (FormatException e) {
-			throw new DbException(e);
-		}
-	}
-
-	private void saveQueueState(Transaction txn, GroupId g,
-			QueueState queueState) throws DbException {
-		try {
-			BdfDictionary d = new BdfDictionary();
-			d.put(OUTGOING_POSITION_KEY, queueState.outgoingPosition);
-			d.put(INCOMING_POSITION_KEY, queueState.incomingPosition);
-			BdfList pendingList = new BdfList();
-			for (Entry<Long, MessageId> e : queueState.pending.entrySet())
-				pendingList.add(BdfList.of(e.getKey(), e.getValue()));
-			d.put(PENDING_MESSAGES_KEY, pendingList);
-			Metadata groupMeta = new Metadata();
-			groupMeta.put(QUEUE_STATE_KEY, clientHelper.toByteArray(d));
-			db.mergeGroupMetadata(txn, g, groupMeta);
-		} catch (FormatException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
-	private static class QueueState {
-
-		private long outgoingPosition, incomingPosition;
-		private final TreeMap<Long, MessageId> pending;
-
-		private QueueState(long outgoingPosition, long incomingPosition,
-				TreeMap<Long, MessageId> pending) {
-			this.outgoingPosition = outgoingPosition;
-			this.incomingPosition = incomingPosition;
-			this.pending = pending;
-		}
-
-		@Nullable
-		MessageId popIncomingMessageId() {
-			Iterator<Entry<Long, MessageId>> it = pending.entrySet().iterator();
-			if (!it.hasNext()) {
-				LOG.info("No pending messages");
-				return null;
-			}
-			Entry<Long, MessageId> e = it.next();
-			if (!e.getKey().equals(incomingPosition)) {
-				if (LOG.isLoggable(INFO)) {
-					LOG.info("First pending message is " + e.getKey() + ", "
-							+ " expecting " + incomingPosition);
-				}
-				return null;
-			}
-			if (LOG.isLoggable(INFO))
-				LOG.info("Removing pending message " + e.getKey());
-			it.remove();
-			incomingPosition++;
-			return e.getValue();
-		}
-	}
-
-	@NotNullByDefault
-	private static class DelegatingMessageValidator
-			implements MessageValidator {
-
-		private final QueueMessageValidator delegate;
-
-		private DelegatingMessageValidator(QueueMessageValidator delegate) {
-			this.delegate = delegate;
-		}
-
-		@Override
-		public MessageContext validateMessage(Message m, Group g)
-				throws InvalidMessageException {
-			byte[] raw = m.getRaw();
-			if (raw.length < QUEUE_MESSAGE_HEADER_LENGTH)
-				throw new InvalidMessageException();
-			long queuePosition = ByteUtils.readUint64(raw,
-					MESSAGE_HEADER_LENGTH);
-			if (queuePosition < 0) throw new InvalidMessageException();
-			QueueMessage q = new QueueMessage(m.getId(), m.getGroupId(),
-					m.getTimestamp(), queuePosition, raw);
-			return delegate.validateMessage(q, g);
-		}
-	}
-
-	@NotNullByDefault
-	private class DelegatingIncomingMessageHook implements IncomingMessageHook {
-
-		private final IncomingQueueMessageHook delegate;
-
-		private DelegatingIncomingMessageHook(
-				IncomingQueueMessageHook delegate) {
-			this.delegate = delegate;
-		}
-
-		@Override
-		public boolean incomingMessage(Transaction txn, Message m,
-				Metadata meta) throws DbException, InvalidMessageException {
-			long queuePosition = ByteUtils.readUint64(m.getRaw(),
-					MESSAGE_HEADER_LENGTH);
-			QueueState queueState = loadQueueState(txn, m.getGroupId());
-			if (LOG.isLoggable(INFO)) {
-				LOG.info("Received message with position  "
-						+ queuePosition + ", expecting "
-						+ queueState.incomingPosition);
-			}
-			if (queuePosition < queueState.incomingPosition) {
-				// A message with this queue position has already been seen
-				LOG.warning("Deleting message with duplicate position");
-				db.deleteMessage(txn, m.getId());
-				db.deleteMessageMetadata(txn, m.getId());
-			} else if (queuePosition > queueState.incomingPosition) {
-				// The message is out of order, add it to the pending list
-				LOG.info("Message is out of order, adding to pending list");
-				queueState.pending.put(queuePosition, m.getId());
-				saveQueueState(txn, m.getGroupId(), queueState);
-			} else {
-				// The message is in order
-				LOG.info("Message is in order, delivering");
-				QueueMessage q = new QueueMessage(m.getId(), m.getGroupId(),
-						m.getTimestamp(), queuePosition, m.getRaw());
-				queueState.incomingPosition++;
-				// Collect any consecutive messages
-				List<MessageId> consecutive = new ArrayList<>();
-				MessageId next;
-				while ((next = queueState.popIncomingMessageId()) != null)
-					consecutive.add(next);
-				// Save the queue state before passing control to the delegate
-				saveQueueState(txn, m.getGroupId(), queueState);
-				// Deliver the messages to the delegate
-				delegate.incomingMessage(txn, q, meta);
-				for (MessageId id : consecutive) {
-					byte[] raw = db.getRawMessage(txn, id);
-					if (raw == null) throw new DbException();
-					meta = db.getMessageMetadata(txn, id);
-					q = queueMessageFactory.createMessage(id, raw);
-					if (LOG.isLoggable(INFO)) {
-						LOG.info("Delivering pending message with position "
-								+ q.getQueuePosition());
-					}
-					delegate.incomingMessage(txn, q, meta);
-				}
-			}
-			// message queues are only useful for groups with two members
-			// so messages don't need to be shared
-			return false;
-		}
-	}
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/client/QueueMessageFactoryImpl.java b/briar-core/src/main/java/org/briarproject/briar/client/QueueMessageFactoryImpl.java
deleted file mode 100644
index 480b7670b8a6d7b56d715a0ef144dea483488621..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/client/QueueMessageFactoryImpl.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package org.briarproject.briar.client;
-
-import org.briarproject.bramble.api.UniqueId;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageFactory;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.util.ByteUtils;
-import org.briarproject.briar.api.client.QueueMessage;
-import org.briarproject.briar.api.client.QueueMessageFactory;
-
-import javax.annotation.concurrent.Immutable;
-import javax.inject.Inject;
-
-import static org.briarproject.bramble.api.sync.SyncConstants.MAX_MESSAGE_LENGTH;
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-import static org.briarproject.bramble.util.ByteUtils.INT_64_BYTES;
-import static org.briarproject.briar.api.client.QueueMessage.MAX_QUEUE_MESSAGE_BODY_LENGTH;
-import static org.briarproject.briar.api.client.QueueMessage.QUEUE_MESSAGE_HEADER_LENGTH;
-
-@Immutable
-@NotNullByDefault
-class QueueMessageFactoryImpl implements QueueMessageFactory {
-
-	private final MessageFactory messageFactory;
-
-	@Inject
-	QueueMessageFactoryImpl(MessageFactory messageFactory) {
-		this.messageFactory = messageFactory;
-	}
-
-	@Override
-	public QueueMessage createMessage(GroupId groupId, long timestamp,
-			long queuePosition, byte[] body) {
-		if (body.length > MAX_QUEUE_MESSAGE_BODY_LENGTH)
-			throw new IllegalArgumentException();
-		byte[] messageBody = new byte[INT_64_BYTES + body.length];
-		ByteUtils.writeUint64(queuePosition, messageBody, 0);
-		System.arraycopy(body, 0, messageBody, INT_64_BYTES, body.length);
-		Message m = messageFactory.createMessage(groupId, timestamp,
-				messageBody);
-		return new QueueMessage(m.getId(), groupId, timestamp, queuePosition,
-				m.getRaw());
-	}
-
-	@Override
-	public QueueMessage createMessage(MessageId id, byte[] raw) {
-		if (raw.length < QUEUE_MESSAGE_HEADER_LENGTH)
-			throw new IllegalArgumentException();
-		if (raw.length > MAX_MESSAGE_LENGTH)
-			throw new IllegalArgumentException();
-		byte[] groupId = new byte[UniqueId.LENGTH];
-		System.arraycopy(raw, 0, groupId, 0, UniqueId.LENGTH);
-		long timestamp = ByteUtils.readUint64(raw, UniqueId.LENGTH);
-		long queuePosition = ByteUtils.readUint64(raw, MESSAGE_HEADER_LENGTH);
-		return new QueueMessage(id, new GroupId(groupId), timestamp,
-				queuePosition, raw);
-	}
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/AbortMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/AbortMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..e9a2d1233489716140048f80642ca2687c3a33de
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/AbortMessage.java
@@ -0,0 +1,27 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class AbortMessage extends AbstractIntroductionMessage {
+
+	private final SessionId sessionId;
+
+	protected AbortMessage(MessageId messageId, GroupId groupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.sessionId = sessionId;
+	}
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/AbstractIntroductionMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/AbstractIntroductionMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..240b5ddecf2e7915041da47b0b7df0e0bc59a7e9
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/AbstractIntroductionMessage.java
@@ -0,0 +1,45 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+abstract class AbstractIntroductionMessage {
+
+	private final MessageId messageId;
+	private final GroupId groupId;
+	private final long timestamp;
+	@Nullable
+	private final MessageId previousMessageId;
+
+	AbstractIntroductionMessage(MessageId messageId, GroupId groupId,
+			long timestamp, @Nullable MessageId previousMessageId) {
+		this.messageId = messageId;
+		this.groupId = groupId;
+		this.timestamp = timestamp;
+		this.previousMessageId = previousMessageId;
+	}
+
+	MessageId getMessageId() {
+		return messageId;
+	}
+
+	GroupId getGroupId() {
+		return groupId;
+	}
+
+	long getTimestamp() {
+		return timestamp;
+	}
+
+	@Nullable
+	MessageId getPreviousMessageId() {
+		return previousMessageId;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/AbstractProtocolEngine.java b/briar-core/src/main/java/org/briarproject/briar/introduction/AbstractProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..c9002bb0e61fb3f8f2989a1fdf308e52afa6858c
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/AbstractProtocolEngine.java
@@ -0,0 +1,191 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.client.ContactGroupFactory;
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.contact.ContactManager;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.briar.api.client.MessageTracker;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.IntroductionResponse;
+import org.briarproject.briar.api.introduction.event.IntroductionResponseReceivedEvent;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.briar.introduction.MessageType.ABORT;
+import static org.briarproject.briar.introduction.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction.MessageType.ACTIVATE;
+import static org.briarproject.briar.introduction.MessageType.AUTH;
+import static org.briarproject.briar.introduction.MessageType.DECLINE;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
+
+@Immutable
+@NotNullByDefault
+abstract class AbstractProtocolEngine<S extends Session>
+		implements ProtocolEngine<S> {
+
+	protected final DatabaseComponent db;
+	protected final ClientHelper clientHelper;
+	protected final ContactManager contactManager;
+	protected final ContactGroupFactory contactGroupFactory;
+	protected final MessageTracker messageTracker;
+	protected final IdentityManager identityManager;
+	protected final MessageParser messageParser;
+	protected final MessageEncoder messageEncoder;
+	protected final Clock clock;
+
+	AbstractProtocolEngine(
+			DatabaseComponent db,
+			ClientHelper clientHelper,
+			ContactManager contactManager,
+			ContactGroupFactory contactGroupFactory,
+			MessageTracker messageTracker,
+			IdentityManager identityManager,
+			MessageParser messageParser,
+			MessageEncoder messageEncoder,
+			Clock clock) {
+		this.db = db;
+		this.clientHelper = clientHelper;
+		this.contactManager = contactManager;
+		this.contactGroupFactory = contactGroupFactory;
+		this.messageTracker = messageTracker;
+		this.identityManager = identityManager;
+		this.messageParser = messageParser;
+		this.messageEncoder = messageEncoder;
+		this.clock = clock;
+	}
+
+	Message sendRequestMessage(Transaction txn, PeerSession s,
+			long timestamp, Author author, @Nullable String message)
+			throws DbException {
+		Message m = messageEncoder
+				.encodeRequestMessage(s.getContactGroupId(), timestamp,
+						s.getLastLocalMessageId(), author, message);
+		sendMessage(txn, REQUEST, s.getSessionId(), m, true);
+		return m;
+	}
+
+	Message sendAcceptMessage(Transaction txn, PeerSession s, long timestamp,
+			byte[] ephemeralPublicKey, long acceptTimestamp,
+			Map<TransportId, TransportProperties> transportProperties,
+			boolean visible)
+			throws DbException {
+		Message m = messageEncoder
+				.encodeAcceptMessage(s.getContactGroupId(), timestamp,
+						s.getLastLocalMessageId(), s.getSessionId(),
+						ephemeralPublicKey, acceptTimestamp,
+						transportProperties);
+		sendMessage(txn, ACCEPT, s.getSessionId(), m, visible);
+		return m;
+	}
+
+	Message sendDeclineMessage(Transaction txn, PeerSession s, long timestamp,
+			boolean visible) throws DbException {
+		Message m = messageEncoder
+				.encodeDeclineMessage(s.getContactGroupId(), timestamp,
+						s.getLastLocalMessageId(), s.getSessionId());
+		sendMessage(txn, DECLINE, s.getSessionId(), m, visible);
+		return m;
+	}
+
+	Message sendAuthMessage(Transaction txn, PeerSession s, long timestamp,
+			byte[] mac, byte[] signature) throws DbException {
+		Message m = messageEncoder
+				.encodeAuthMessage(s.getContactGroupId(), timestamp,
+						s.getLastLocalMessageId(), s.getSessionId(), mac,
+						signature);
+		sendMessage(txn, AUTH, s.getSessionId(), m, false);
+		return m;
+	}
+
+	Message sendActivateMessage(Transaction txn, PeerSession s, long timestamp,
+			byte[] mac) throws DbException {
+		Message m = messageEncoder
+				.encodeActivateMessage(s.getContactGroupId(), timestamp,
+						s.getLastLocalMessageId(), s.getSessionId(), mac);
+		sendMessage(txn, ACTIVATE, s.getSessionId(), m, false);
+		return m;
+	}
+
+	Message sendAbortMessage(Transaction txn, PeerSession s, long timestamp)
+			throws DbException {
+		Message m = messageEncoder
+				.encodeAbortMessage(s.getContactGroupId(), timestamp,
+						s.getLastLocalMessageId(), s.getSessionId());
+		sendMessage(txn, ABORT, s.getSessionId(), m, false);
+		return m;
+	}
+
+	private void sendMessage(Transaction txn, MessageType type,
+			SessionId sessionId, Message m, boolean visibleInConversation)
+			throws DbException {
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(type, sessionId, m.getTimestamp(), true, true,
+						visibleInConversation);
+		try {
+			clientHelper.addLocalMessage(txn, m, meta, true);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	void broadcastIntroductionResponseReceivedEvent(Transaction txn,
+			Session s, AuthorId sender, AbstractIntroductionMessage m)
+			throws DbException {
+		AuthorId localAuthorId = identityManager.getLocalAuthor(txn).getId();
+		Contact c = contactManager.getContact(txn, sender, localAuthorId);
+		IntroductionResponse response =
+				new IntroductionResponse(s.getSessionId(), m.getMessageId(),
+						m.getGroupId(), s.getRole(), m.getTimestamp(), false,
+						false, false, false, c.getAuthor().getName(),
+						m instanceof AcceptMessage);
+		IntroductionResponseReceivedEvent e =
+				new IntroductionResponseReceivedEvent(c.getId(), response);
+		txn.attach(e);
+	}
+
+	void markMessageVisibleInUi(Transaction txn, MessageId m)
+			throws DbException {
+		BdfDictionary meta = new BdfDictionary();
+		messageEncoder.setVisibleInUi(meta, true);
+		try {
+			clientHelper.mergeMessageMetadata(txn, m, meta);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	boolean isInvalidDependency(@Nullable MessageId lastRemoteMessageId,
+			@Nullable MessageId dependency) {
+		if (dependency == null) return lastRemoteMessageId != null;
+		return lastRemoteMessageId == null ||
+				!dependency.equals(lastRemoteMessageId);
+	}
+
+	long getLocalTimestamp(long localTimestamp, long requestTimestamp) {
+		return Math.max(
+				clock.currentTimeMillis(),
+				Math.max(
+						localTimestamp,
+						requestTimestamp
+				) + 1
+		);
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/AcceptMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/AcceptMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..6cadae73b6257bbe76c67d5904a47dba286838c3
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/AcceptMessage.java
@@ -0,0 +1,53 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class AcceptMessage extends AbstractIntroductionMessage {
+
+	private final SessionId sessionId;
+	private final byte[] ephemeralPublicKey;
+	private final long acceptTimestamp;
+	private final Map<TransportId, TransportProperties> transportProperties;
+
+	protected AcceptMessage(MessageId messageId, GroupId groupId,
+			long timestamp, @Nullable MessageId previousMessageId,
+			SessionId sessionId,
+			byte[] ephemeralPublicKey,
+			long acceptTimestamp,
+			Map<TransportId, TransportProperties> transportProperties) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.sessionId = sessionId;
+		this.ephemeralPublicKey = ephemeralPublicKey;
+		this.acceptTimestamp = acceptTimestamp;
+		this.transportProperties = transportProperties;
+	}
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+	public byte[] getEphemeralPublicKey() {
+		return ephemeralPublicKey;
+	}
+
+	public long getAcceptTimestamp() {
+		return acceptTimestamp;
+	}
+
+	public Map<TransportId, TransportProperties> getTransportProperties() {
+		return transportProperties;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/ActivateMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/ActivateMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..5f767737d90fe68029f80e2e0ab4d87932df13ce
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/ActivateMessage.java
@@ -0,0 +1,33 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class ActivateMessage extends AbstractIntroductionMessage {
+
+	private final SessionId sessionId;
+	private final byte[] mac;
+
+	protected ActivateMessage(MessageId messageId, GroupId groupId,
+			long timestamp, MessageId previousMessageId, SessionId sessionId,
+			byte[] mac) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.sessionId = sessionId;
+		this.mac = mac;
+	}
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+	public byte[] getMac() {
+		return mac;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/AuthMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/AuthMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..1de1a4eb5803cbbfef2fb82c6fbc99e88ea09f5d
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/AuthMessage.java
@@ -0,0 +1,38 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class AuthMessage extends AbstractIntroductionMessage {
+
+	private final SessionId sessionId;
+	private final byte[] mac, signature;
+
+	protected AuthMessage(MessageId messageId, GroupId groupId,
+			long timestamp, MessageId previousMessageId, SessionId sessionId,
+			byte[] mac, byte[] signature) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.sessionId = sessionId;
+		this.mac = mac;
+		this.signature = signature;
+	}
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+	public byte[] getMac() {
+		return mac;
+	}
+
+	public byte[] getSignature() {
+		return signature;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/DeclineMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/DeclineMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..27386b90587a7297174ffbbf27e2ef82544b2444
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/DeclineMessage.java
@@ -0,0 +1,28 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class DeclineMessage extends AbstractIntroductionMessage {
+
+	private final SessionId sessionId;
+
+	protected DeclineMessage(MessageId messageId, GroupId groupId,
+			long timestamp, @Nullable MessageId previousMessageId,
+			SessionId sessionId) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.sessionId = sessionId;
+	}
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeEngine.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeEngine.java
deleted file mode 100644
index 2d5bb95c3ad591c57b5aabb0889f48c4803d93ab..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeEngine.java
+++ /dev/null
@@ -1,372 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.identity.AuthorId;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.briar.api.client.ProtocolEngine;
-import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.api.introduction.IntroduceeAction;
-import org.briarproject.briar.api.introduction.IntroduceeProtocolState;
-import org.briarproject.briar.api.introduction.IntroductionRequest;
-import org.briarproject.briar.api.introduction.event.IntroductionAbortedEvent;
-import org.briarproject.briar.api.introduction.event.IntroductionRequestReceivedEvent;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.logging.Logger;
-
-import javax.annotation.concurrent.Immutable;
-
-import static java.util.logging.Level.INFO;
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.ACK;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.LOCAL_ABORT;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.LOCAL_ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.LOCAL_DECLINE;
-import static org.briarproject.briar.api.introduction.IntroduceeAction.REMOTE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.AWAIT_ACK;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.AWAIT_REMOTE_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.AWAIT_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.AWAIT_RESPONSES;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.ERROR;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.FINISHED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ANSWERED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.EXISTS;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NOT_OUR_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_IS_US;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCEE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK_ACTIVATE_CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK_ADD_CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-
-@Immutable
-@NotNullByDefault
-class IntroduceeEngine
-		implements ProtocolEngine<BdfDictionary, BdfDictionary, BdfDictionary> {
-
-	private static final Logger LOG =
-			Logger.getLogger(IntroduceeEngine.class.getName());
-
-	@Override
-	public StateUpdate<BdfDictionary, BdfDictionary> onLocalAction(
-			BdfDictionary localState, BdfDictionary localAction) {
-
-		try {
-			IntroduceeProtocolState currentState =
-					getState(localState.getLong(STATE));
-			int type = localAction.getLong(TYPE).intValue();
-			IntroduceeAction action;
-			if (localState.containsKey(ACCEPT)) action = IntroduceeAction
-					.getLocal(type, localState.getBoolean(ACCEPT));
-			else action = IntroduceeAction.getLocal(type);
-			IntroduceeProtocolState nextState = currentState.next(action);
-
-			if (action == LOCAL_ABORT && currentState != ERROR) {
-				return abortSession(currentState, localState);
-			}
-
-			if (nextState == ERROR) {
-				if (LOG.isLoggable(WARNING)) {
-					LOG.warning("Error: Invalid action in state " +
-							currentState.name());
-				}
-				if (currentState == ERROR) return noUpdate(localState);
-				else return abortSession(currentState, localState);
-			}
-
-			List<BdfDictionary> messages = new ArrayList<>(1);
-			if (action == LOCAL_ACCEPT || action == LOCAL_DECLINE) {
-				localState.put(STATE, nextState.getValue());
-				localState.put(ANSWERED, true);
-				// create the introduction response message
-				BdfDictionary msg = new BdfDictionary();
-				msg.put(TYPE, TYPE_RESPONSE);
-				msg.put(GROUP_ID, localState.getRaw(GROUP_ID));
-				msg.put(SESSION_ID, localState.getRaw(SESSION_ID));
-				msg.put(ACCEPT, localState.getBoolean(ACCEPT));
-				if (localState.getBoolean(ACCEPT)) {
-					msg.put(TIME, localState.getLong(OUR_TIME));
-					msg.put(E_PUBLIC_KEY, localState.getRaw(OUR_PUBLIC_KEY));
-					msg.put(TRANSPORT, localAction.getDictionary(TRANSPORT));
-				}
-				msg.put(MESSAGE_TIME, localAction.getLong(MESSAGE_TIME));
-				messages.add(msg);
-				logAction(currentState, localState, msg);
-
-				if (nextState == AWAIT_ACK) {
-					localState.put(TASK, TASK_ADD_CONTACT);
-				}
-			} else if (action == ACK) {
-				// just send ACK, don't update local state again
-				BdfDictionary ack = getAckMessage(localState);
-				messages.add(ack);
-			} else {
-				throw new IllegalArgumentException();
-			}
-			List<Event> events = Collections.emptyList();
-			return new StateUpdate<>(false, false,
-					localState, messages, events);
-		} catch (FormatException e) {
-			throw new IllegalArgumentException(e);
-		}
-	}
-
-	@Override
-	public StateUpdate<BdfDictionary, BdfDictionary> onMessageReceived(
-			BdfDictionary localState, BdfDictionary msg) {
-
-		try {
-			IntroduceeProtocolState currentState =
-					getState(localState.getLong(STATE));
-			int type = msg.getLong(TYPE).intValue();
-			IntroduceeAction action = IntroduceeAction.getRemote(type);
-			IntroduceeProtocolState nextState = currentState.next(action);
-
-			logMessageReceived(currentState, nextState, localState, type, msg);
-
-			if (nextState == ERROR) {
-				if (currentState != ERROR && action != REMOTE_ABORT) {
-					return abortSession(currentState, localState);
-				} else {
-					return noUpdate(localState);
-				}
-			}
-
-			// update local session state with next protocol state
-			localState.put(STATE, nextState.getValue());
-			List<BdfDictionary> messages;
-			List<Event> events;
-			// we received the introduction request
-			if (currentState == AWAIT_REQUEST) {
-				// remember the session ID used by the introducer
-				localState.put(SESSION_ID, msg.getRaw(SESSION_ID));
-
-				addRequestData(localState, msg);
-				messages = Collections.emptyList();
-				events = Collections.singletonList(getEvent(localState, msg));
-			}
-			// we had the request and now one response came in _OR_
-			// we had sent our response already and now received the other one
-			else if (currentState == AWAIT_RESPONSES ||
-					currentState == AWAIT_REMOTE_RESPONSE) {
-				// update next state based on message content
-				action = IntroduceeAction
-						.getRemote(type, msg.getBoolean(ACCEPT));
-				nextState = currentState.next(action);
-				localState.put(STATE, nextState.getValue());
-
-				addResponseData(localState, msg);
-				if (nextState == AWAIT_ACK) {
-					localState.put(TASK, TASK_ADD_CONTACT);
-				}
-				messages = Collections.emptyList();
-				events = Collections.emptyList();
-			}
-			// we already sent our ACK and now received the other one
-			else if (currentState == AWAIT_ACK) {
-				localState.put(TASK, TASK_ACTIVATE_CONTACT);
-				addAckData(localState, msg);
-				messages = Collections.emptyList();
-				events = Collections.emptyList();
-			}
-			// we are done (probably declined response), ignore & delete message
-			else if (currentState == FINISHED) {
-				return new StateUpdate<>(true, false, localState,
-						Collections.<BdfDictionary>emptyList(),
-						Collections.emptyList());
-			}
-			// this should not happen
-			else {
-				throw new IllegalArgumentException();
-			}
-			return new StateUpdate<>(false, false,
-					localState, messages, events);
-		} catch (FormatException e) {
-			throw new IllegalArgumentException(e);
-		}
-	}
-
-	private void addRequestData(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		localState.put(NAME, msg.getString(NAME));
-		localState.put(PUBLIC_KEY, msg.getRaw(PUBLIC_KEY));
-		if (msg.containsKey(MSG)) {
-			localState.put(MSG, msg.getString(MSG));
-		}
-	}
-
-	private void addResponseData(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		if (localState.containsKey(ACCEPT)) {
-			localState.put(ACCEPT,
-					localState.getBoolean(ACCEPT) && msg.getBoolean(ACCEPT));
-		} else {
-			localState.put(ACCEPT, msg.getBoolean(ACCEPT));
-		}
-		localState.put(NOT_OUR_RESPONSE, msg.getRaw(MESSAGE_ID));
-
-		if (msg.getBoolean(ACCEPT)) {
-			localState.put(TIME, msg.getLong(TIME));
-			localState.put(E_PUBLIC_KEY, msg.getRaw(E_PUBLIC_KEY));
-			localState.put(TRANSPORT, msg.getDictionary(TRANSPORT));
-		}
-	}
-
-	private void addAckData(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		localState.put(MAC, msg.getRaw(MAC));
-		localState.put(SIGNATURE, msg.getRaw(SIGNATURE));
-	}
-
-	private BdfDictionary getAckMessage(BdfDictionary localState)
-			throws FormatException {
-
-		BdfDictionary m = new BdfDictionary();
-		m.put(TYPE, TYPE_ACK);
-		m.put(GROUP_ID, localState.getRaw(GROUP_ID));
-		m.put(SESSION_ID, localState.getRaw(SESSION_ID));
-		m.put(MAC, localState.getRaw(OUR_MAC));
-		m.put(SIGNATURE, localState.getRaw(OUR_SIGNATURE));
-		return m;
-	}
-
-	private void logAction(IntroduceeProtocolState state,
-			BdfDictionary localState, BdfDictionary msg) {
-
-		if (!LOG.isLoggable(INFO)) return;
-
-		try {
-			LOG.info("Sending " +
-					(localState.getBoolean(ACCEPT) ? "accept " : "decline ") +
-					"response in state " + state.name());
-			LOG.info("Moving on to state " +
-					getState(localState.getLong(STATE)).name());
-		} catch (FormatException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-	}
-
-	private void logMessageReceived(IntroduceeProtocolState currentState,
-			IntroduceeProtocolState nextState, BdfDictionary localState,
-			int type, BdfDictionary msg) {
-
-		if (!LOG.isLoggable(INFO)) return;
-
-		String t = "unknown";
-		if (type == TYPE_REQUEST) t = "Introduction";
-		else if (type == TYPE_RESPONSE) t = "Response";
-		else if (type == TYPE_ACK) t = "ACK";
-		else if (type == TYPE_ABORT) t = "Abort";
-
-		LOG.info("Received " + t + " in state " + currentState.name());
-		LOG.info("Moving on to state " + nextState.name());
-	}
-
-	@Override
-	public StateUpdate<BdfDictionary, BdfDictionary> onMessageDelivered(
-			BdfDictionary localState, BdfDictionary delivered) {
-		try {
-			return noUpdate(localState);
-		} catch (FormatException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-			return null;
-		}
-	}
-
-	private IntroduceeProtocolState getState(Long state) {
-		return IntroduceeProtocolState.fromValue(state.intValue());
-	}
-
-	private Event getEvent(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		ContactId contactId =
-				new ContactId(localState.getLong(CONTACT_ID_1).intValue());
-		AuthorId authorId = new AuthorId(localState.getRaw(REMOTE_AUTHOR_ID));
-
-		SessionId sessionId = new SessionId(localState.getRaw(SESSION_ID));
-		MessageId messageId = new MessageId(msg.getRaw(MESSAGE_ID));
-		GroupId groupId = new GroupId(msg.getRaw(GROUP_ID));
-		long time = msg.getLong(MESSAGE_TIME);
-		String name = msg.getString(NAME);
-		String message = msg.getOptionalString(MSG);
-		boolean exists = localState.getBoolean(EXISTS);
-		boolean introducesOtherIdentity =
-				localState.getBoolean(REMOTE_AUTHOR_IS_US);
-
-		IntroductionRequest ir = new IntroductionRequest(sessionId, messageId,
-				groupId, ROLE_INTRODUCEE, time, false, false, false, false,
-				authorId, name, false, message, false, exists,
-				introducesOtherIdentity);
-		return new IntroductionRequestReceivedEvent(contactId, ir);
-	}
-
-	private StateUpdate<BdfDictionary, BdfDictionary> abortSession(
-			IntroduceeProtocolState currentState, BdfDictionary localState)
-			throws FormatException {
-
-		if (LOG.isLoggable(WARNING))
-			LOG.warning("Aborting protocol session in state " +
-					currentState.name());
-
-		localState.put(STATE, ERROR.getValue());
-		localState.put(TASK, TASK_ABORT);
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_ABORT);
-		msg.put(GROUP_ID, localState.getRaw(GROUP_ID));
-		msg.put(SESSION_ID, localState.getRaw(SESSION_ID));
-		List<BdfDictionary> messages = Collections.singletonList(msg);
-
-		// send abort event
-		ContactId contactId =
-				new ContactId(localState.getLong(CONTACT_ID_1).intValue());
-		SessionId sessionId = new SessionId(localState.getRaw(SESSION_ID));
-		Event event = new IntroductionAbortedEvent(contactId, sessionId);
-		List<Event> events = Collections.singletonList(event);
-
-		return new StateUpdate<>(false, false, localState, messages, events);
-	}
-
-	private StateUpdate<BdfDictionary, BdfDictionary> noUpdate(
-			BdfDictionary localState) throws FormatException {
-
-		return new StateUpdate<>(false, false, localState,
-				Collections.<BdfDictionary>emptyList(),
-				Collections.emptyList());
-	}
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeManager.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeManager.java
deleted file mode 100644
index 4666ac92e68bdf30621f35fa33e3801c221ef23f..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeManager.java
+++ /dev/null
@@ -1,569 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.Bytes;
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.contact.Contact;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.contact.ContactManager;
-import org.briarproject.bramble.api.crypto.CryptoComponent;
-import org.briarproject.bramble.api.crypto.KeyPair;
-import org.briarproject.bramble.api.crypto.KeyParser;
-import org.briarproject.bramble.api.crypto.PrivateKey;
-import org.briarproject.bramble.api.crypto.PublicKey;
-import org.briarproject.bramble.api.crypto.SecretKey;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.identity.Author;
-import org.briarproject.bramble.api.identity.AuthorFactory;
-import org.briarproject.bramble.api.identity.AuthorId;
-import org.briarproject.bramble.api.identity.IdentityManager;
-import org.briarproject.bramble.api.identity.LocalAuthor;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.plugin.TransportId;
-import org.briarproject.bramble.api.properties.TransportProperties;
-import org.briarproject.bramble.api.properties.TransportPropertyManager;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.api.introduction.event.IntroductionSucceededEvent;
-
-import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.logging.Logger;
-
-import javax.annotation.Nullable;
-import javax.annotation.concurrent.Immutable;
-import javax.inject.Inject;
-
-import static java.util.logging.Level.INFO;
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.bramble.api.data.BdfDictionary.NULL_VALUE;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.AWAIT_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ADDED_CONTACT_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ALICE_MAC_KEY_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ALICE_NONCE_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ANSWERED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.BOB_MAC_KEY_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.BOB_NONCE_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.EXISTS;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.LOCAL_AUTHOR_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NONCE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NOT_OUR_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_PRIVATE_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.OUR_TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_IS_US;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCEE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SHARED_SECRET_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNING_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STORAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK_ACTIVATE_CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TASK_ADD_CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_VERSION;
-
-@Immutable
-@NotNullByDefault
-class IntroduceeManager {
-
-	private static final Logger LOG =
-			Logger.getLogger(IntroduceeManager.class.getName());
-
-	private final MessageSender messageSender;
-	private final DatabaseComponent db;
-	private final ClientHelper clientHelper;
-	private final Clock clock;
-	private final CryptoComponent cryptoComponent;
-	private final TransportPropertyManager transportPropertyManager;
-	private final AuthorFactory authorFactory;
-	private final ContactManager contactManager;
-	private final IdentityManager identityManager;
-	private final IntroductionGroupFactory introductionGroupFactory;
-
-	@Inject
-	IntroduceeManager(MessageSender messageSender, DatabaseComponent db,
-			ClientHelper clientHelper, Clock clock,
-			CryptoComponent cryptoComponent,
-			TransportPropertyManager transportPropertyManager,
-			AuthorFactory authorFactory, ContactManager contactManager,
-			IdentityManager identityManager,
-			IntroductionGroupFactory introductionGroupFactory) {
-
-		this.messageSender = messageSender;
-		this.db = db;
-		this.clientHelper = clientHelper;
-		this.clock = clock;
-		this.cryptoComponent = cryptoComponent;
-		this.transportPropertyManager = transportPropertyManager;
-		this.authorFactory = authorFactory;
-		this.contactManager = contactManager;
-		this.identityManager = identityManager;
-		this.introductionGroupFactory = introductionGroupFactory;
-	}
-
-	public BdfDictionary initialize(Transaction txn, GroupId groupId,
-			BdfDictionary message) throws DbException, FormatException {
-
-		// create local message to keep engine state
-		long now = clock.currentTimeMillis();
-		Bytes salt = new Bytes(new byte[64]);
-		cryptoComponent.getSecureRandom().nextBytes(salt.getBytes());
-
-		Message localMsg = clientHelper.createMessage(
-				introductionGroupFactory.createLocalGroup().getId(), now,
-				BdfList.of(salt));
-		MessageId storageId = localMsg.getId();
-
-		// find out who is introducing us
-		BdfDictionary gd =
-				clientHelper.getGroupMetadataAsDictionary(txn, groupId);
-		ContactId introducerId =
-				new ContactId(gd.getLong(CONTACT).intValue());
-		Contact introducer = db.getContact(txn, introducerId);
-
-		BdfDictionary d = new BdfDictionary();
-		d.put(STORAGE_ID, storageId);
-		d.put(STATE, AWAIT_REQUEST.getValue());
-		d.put(ROLE, ROLE_INTRODUCEE);
-		d.put(GROUP_ID, groupId);
-		d.put(INTRODUCER, introducer.getAuthor().getName());
-		d.put(CONTACT_ID_1, introducer.getId().getInt());
-		d.put(LOCAL_AUTHOR_ID, introducer.getLocalAuthorId().getBytes());
-		d.put(NOT_OUR_RESPONSE, storageId);
-		d.put(ANSWERED, false);
-
-		// check if the contact we are introduced to does already exist
-		// TODO: Exchange author format version
-		AuthorId remoteAuthorId = authorFactory
-				.createAuthor(message.getString(NAME),
-						message.getRaw(PUBLIC_KEY)).getId();
-		boolean exists = contactManager.contactExists(txn, remoteAuthorId,
-				introducer.getLocalAuthorId());
-		d.put(EXISTS, exists);
-		d.put(REMOTE_AUTHOR_ID, remoteAuthorId);
-
-		// check if someone is trying to introduce us to ourselves
-		if (remoteAuthorId.equals(introducer.getLocalAuthorId())) {
-			LOG.warning("Received Introduction Request to Ourselves");
-			throw new FormatException();
-		}
-
-		// check if remote author is actually one of our other identities
-		boolean introducesOtherIdentity =
-				db.containsLocalAuthor(txn, remoteAuthorId);
-		d.put(REMOTE_AUTHOR_IS_US, introducesOtherIdentity);
-
-		// save local state to database
-		clientHelper.addLocalMessage(txn, localMsg, d, false);
-
-		return d;
-	}
-
-	public void incomingMessage(Transaction txn, BdfDictionary state,
-			BdfDictionary message) throws DbException, FormatException {
-
-		IntroduceeEngine engine = new IntroduceeEngine();
-		processStateUpdate(txn, message,
-				engine.onMessageReceived(state, message));
-	}
-
-	void acceptIntroduction(Transaction txn, BdfDictionary state,
-			long timestamp) throws DbException, FormatException {
-
-		// get data to connect and derive a shared secret later
-		long now = clock.currentTimeMillis();
-		KeyPair keyPair = cryptoComponent.generateAgreementKeyPair();
-		byte[] publicKey = keyPair.getPublic().getEncoded();
-		byte[] privateKey = keyPair.getPrivate().getEncoded();
-		Map<TransportId, TransportProperties> transportProperties =
-				transportPropertyManager.getLocalProperties(txn);
-		BdfDictionary tp = encodeTransportProperties(transportProperties);
-
-		// update session state for later
-		state.put(ACCEPT, true);
-		state.put(OUR_TIME, now);
-		state.put(OUR_PUBLIC_KEY, publicKey);
-		state.put(OUR_PRIVATE_KEY, privateKey);
-		state.put(OUR_TRANSPORT, tp);
-
-		// define action
-		BdfDictionary localAction = new BdfDictionary();
-		localAction.put(TYPE, TYPE_RESPONSE);
-		localAction.put(TRANSPORT, tp);
-		localAction.put(MESSAGE_TIME, timestamp);
-
-		// start engine and process its state update
-		IntroduceeEngine engine = new IntroduceeEngine();
-		processStateUpdate(txn, null, engine.onLocalAction(state, localAction));
-	}
-
-	void declineIntroduction(Transaction txn, BdfDictionary state,
-			long timestamp) throws DbException, FormatException {
-
-		// update session state
-		state.put(ACCEPT, false);
-
-		// define action
-		BdfDictionary localAction = new BdfDictionary();
-		localAction.put(TYPE, TYPE_RESPONSE);
-		localAction.put(MESSAGE_TIME, timestamp);
-
-		// start engine and process its state update
-		IntroduceeEngine engine = new IntroduceeEngine();
-		processStateUpdate(txn, null,
-				engine.onLocalAction(state, localAction));
-	}
-
-	private void processStateUpdate(Transaction txn,
-			@Nullable BdfDictionary msg,
-			IntroduceeEngine.StateUpdate<BdfDictionary, BdfDictionary> result)
-			throws DbException, FormatException {
-
-		// perform actions based on new local state
-		BdfDictionary followUpAction = performTasks(txn, result.localState);
-
-		// save new local state
-		MessageId storageId =
-				new MessageId(result.localState.getRaw(STORAGE_ID));
-		clientHelper.mergeMessageMetadata(txn, storageId, result.localState);
-
-		// send messages
-		for (BdfDictionary d : result.toSend) {
-			messageSender.sendMessage(txn, d);
-		}
-
-		// broadcast events
-		for (Event event : result.toBroadcast) {
-			txn.attach(event);
-		}
-
-		// delete message
-		if (result.deleteMessage && msg != null) {
-			MessageId messageId = new MessageId(msg.getRaw(MESSAGE_ID));
-			if (LOG.isLoggable(INFO)) {
-				LOG.info("Deleting message with id " + messageId.hashCode());
-			}
-			db.deleteMessage(txn, messageId);
-			db.deleteMessageMetadata(txn, messageId);
-		}
-
-		// process follow up action at the end if available
-		if (followUpAction != null) {
-			IntroduceeEngine engine = new IntroduceeEngine();
-			processStateUpdate(txn, null,
-					engine.onLocalAction(result.localState, followUpAction));
-		}
-	}
-
-	@Nullable
-	private BdfDictionary performTasks(Transaction txn,
-			BdfDictionary localState) throws FormatException, DbException {
-
-		if (!localState.containsKey(TASK) || localState.get(TASK) == NULL_VALUE)
-			return null;
-
-		// remember task and remove it from localState
-		long task = localState.getLong(TASK);
-		localState.put(TASK, NULL_VALUE);
-
-		if (task == TASK_ADD_CONTACT) {
-			if (localState.getBoolean(EXISTS)) {
-				// we have this contact already, so do not perform actions
-				LOG.info("We have this contact already, do not add");
-				return null;
-			}
-
-			// figure out who takes which role by comparing public keys
-			byte[] ourPublicKeyBytes = localState.getRaw(OUR_PUBLIC_KEY);
-			byte[] theirPublicKeyBytes = localState.getRaw(E_PUBLIC_KEY);
-			int comp = Bytes.COMPARATOR.compare(new Bytes(ourPublicKeyBytes),
-					new Bytes(theirPublicKeyBytes));
-			boolean alice = comp < 0;
-
-			// get our local author
-			LocalAuthor author = identityManager.getLocalAuthor(txn);
-
-			SecretKey secretKey;
-			byte[] ourPrivateKeyBytes = localState.getRaw(OUR_PRIVATE_KEY);
-			try {
-				// derive secret master key
-				secretKey = deriveSecretKey(ourPublicKeyBytes,
-						ourPrivateKeyBytes, alice, theirPublicKeyBytes);
-				// derive MAC keys and nonces, sign our nonce and calculate MAC
-				deriveMacKeysAndNonces(localState, author, secretKey, alice);
-			} catch (GeneralSecurityException e) {
-				// we can not continue without the signature
-				throw new DbException(e);
-			}
-
-			LOG.info("Adding contact in inactive state");
-
-			// The agreed timestamp is the minimum of the peers' timestamps
-			long ourTime = localState.getLong(OUR_TIME);
-			long theirTime = localState.getLong(TIME);
-			long timestamp = Math.min(ourTime, theirTime);
-
-			// Add the contact to the database as inactive
-			// TODO: Exchange author format version
-			Author remoteAuthor = authorFactory
-					.createAuthor(localState.getString(NAME),
-							localState.getRaw(PUBLIC_KEY));
-			ContactId contactId = contactManager
-					.addContact(txn, remoteAuthor, author.getId(), secretKey,
-							timestamp, alice, false, false);
-
-			// Update local state with ContactId, so we know what to activate
-			localState.put(ADDED_CONTACT_ID, contactId.getInt());
-
-			// let the transport manager know how to connect to the contact
-			Map<TransportId, TransportProperties> transportProperties =
-					parseTransportProperties(localState);
-			transportPropertyManager.addRemoteProperties(txn, contactId,
-					transportProperties);
-
-			// delete the ephemeral private key by overwriting with NULL value
-			// this ensures future ephemeral keys can not be recovered when
-			// this device should gets compromised
-			localState.put(OUR_PRIVATE_KEY, NULL_VALUE);
-
-			// define next action: Send ACK
-			BdfDictionary localAction = new BdfDictionary();
-			localAction.put(TYPE, TYPE_ACK);
-
-			// return follow up action to start engine
-			// and process its state update again
-			return localAction;
-		}
-
-		// we sent and received an ACK, so activate contact
-		if (task == TASK_ACTIVATE_CONTACT) {
-			if (!localState.getBoolean(EXISTS) &&
-					localState.containsKey(ADDED_CONTACT_ID)) {
-				try {
-					LOG.info("Verifying Signature...");
-					verifySignature(localState);
-					LOG.info("Verifying MAC...");
-					verifyMac(localState);
-				} catch (GeneralSecurityException e) {
-					throw new DbException(e);
-				}
-
-				LOG.info("Activating Contact...");
-
-				ContactId contactId = new ContactId(
-						localState.getLong(ADDED_CONTACT_ID).intValue());
-
-				// activate and show contact in contact list
-				contactManager.setContactActive(txn, contactId, true);
-
-				// broadcast event informing of successful introduction
-				Contact contact = db.getContact(txn, contactId);
-				Event event = new IntroductionSucceededEvent(contact);
-				txn.attach(event);
-			} else {
-				LOG.info(
-						"We must have had this contact already, not activating...");
-			}
-		}
-
-		// we need to abort the protocol, clean up what has been done
-		if (task == TASK_ABORT) {
-			if (localState.containsKey(ADDED_CONTACT_ID)) {
-				LOG.info("Deleting added contact due to abort...");
-				ContactId contactId = new ContactId(
-						localState.getLong(ADDED_CONTACT_ID).intValue());
-				contactManager.removeContact(txn, contactId);
-			}
-		}
-		return null;
-	}
-
-	private SecretKey deriveSecretKey(byte[] ourPublicKeyBytes,
-			byte[] ourPrivateKeyBytes, boolean alice,
-			byte[] theirPublicKeyBytes) throws GeneralSecurityException {
-		// parse the local ephemeral key pair
-		KeyParser keyParser = cryptoComponent.getAgreementKeyParser();
-		PublicKey ourPublicKey;
-		PrivateKey ourPrivateKey;
-		try {
-			ourPublicKey = keyParser.parsePublicKey(ourPublicKeyBytes);
-			ourPrivateKey = keyParser.parsePrivateKey(ourPrivateKeyBytes);
-		} catch (GeneralSecurityException e) {
-			if (LOG.isLoggable(WARNING)) {
-				LOG.log(WARNING, e.toString(), e);
-			}
-			throw new RuntimeException("Our own ephemeral key is invalid");
-		}
-		KeyPair ourKeyPair = new KeyPair(ourPublicKey, ourPrivateKey);
-		PublicKey theirPublicKey =
-				keyParser.parsePublicKey(theirPublicKeyBytes);
-
-		// The shared secret is derived from the local ephemeral key pair
-		// and the remote ephemeral public key
-		byte[][] inputs = {
-				new byte[] {CLIENT_VERSION},
-				alice ? ourPublicKeyBytes : theirPublicKeyBytes,
-				alice ? theirPublicKeyBytes : ourPublicKeyBytes
-		};
-		return cryptoComponent.deriveSharedSecret(SHARED_SECRET_LABEL,
-				theirPublicKey, ourKeyPair, inputs);
-	}
-
-	/**
-	 * Derives nonces, signs our nonce and calculates MAC
-	 * <p>
-	 * Derives two nonces and two MAC keys from the shared secret key.
-	 * The other introducee's nonce and MAC key are added to the localState.
-	 * <p>
-	 * Our nonce is signed with the local author's long-term private key.
-	 * The signature is added to the localState.
-	 * <p>
-	 * Calculates a MAC and stores it in the localState.
-	 */
-	private void deriveMacKeysAndNonces(BdfDictionary localState,
-			LocalAuthor author, SecretKey secretKey, boolean alice)
-			throws FormatException, GeneralSecurityException {
-		// Derive two nonces and two MAC keys from the shared secret key
-		String ourNonceLabel = alice ? ALICE_NONCE_LABEL : BOB_NONCE_LABEL;
-		String theirNonceLabel = alice ? BOB_NONCE_LABEL : ALICE_NONCE_LABEL;
-		byte[] ourNonce = cryptoComponent.mac(ourNonceLabel, secretKey);
-		byte[] theirNonce = cryptoComponent.mac(theirNonceLabel, secretKey);
-		String ourKeyLabel = alice ? ALICE_MAC_KEY_LABEL : BOB_MAC_KEY_LABEL;
-		String theirKeyLabel = alice ? BOB_MAC_KEY_LABEL : ALICE_MAC_KEY_LABEL;
-		SecretKey ourMacKey = cryptoComponent.deriveKey(ourKeyLabel, secretKey);
-		SecretKey theirMacKey =
-				cryptoComponent.deriveKey(theirKeyLabel, secretKey);
-
-		// Save the other nonce and MAC key for the verification
-		localState.put(NONCE, theirNonce);
-		localState.put(MAC_KEY, theirMacKey.getBytes());
-
-		// Sign our nonce with our long-term identity public key
-		byte[] sig = cryptoComponent.sign(SIGNING_LABEL, ourNonce,
-				author.getPrivateKey());
-
-		// Calculate a MAC over identity public key, ephemeral public key,
-		// transport properties and timestamp.
-		byte[] publicKeyBytes = localState.getRaw(OUR_PUBLIC_KEY);
-		BdfDictionary tp = localState.getDictionary(OUR_TRANSPORT);
-		long ourTime = localState.getLong(OUR_TIME);
-		BdfList toMacList = BdfList.of(author.getPublicKey(),
-				publicKeyBytes, tp, ourTime);
-		byte[] toMac = clientHelper.toByteArray(toMacList);
-		byte[] mac = cryptoComponent.mac(MAC_LABEL, ourMacKey, toMac);
-
-		// Add MAC and signature to localState, so it can be included in ACK
-		localState.put(OUR_MAC, mac);
-		localState.put(OUR_SIGNATURE, sig);
-	}
-
-	void verifySignature(BdfDictionary localState)
-			throws FormatException, GeneralSecurityException {
-		byte[] nonce = localState.getRaw(NONCE);
-		byte[] sig = localState.getRaw(SIGNATURE);
-		byte[] key = localState.getRaw(PUBLIC_KEY);
-
-		// Verify the signature
-		if (!cryptoComponent.verifySignature(sig, SIGNING_LABEL, nonce, key)) {
-			LOG.warning("Invalid nonce signature in ACK");
-			throw new GeneralSecurityException();
-		}
-	}
-
-	void verifyMac(BdfDictionary localState)
-			throws FormatException, GeneralSecurityException {
-		// get MAC and MAC key from session state
-		byte[] mac = localState.getRaw(MAC);
-		byte[] macKeyBytes = localState.getRaw(MAC_KEY);
-		SecretKey macKey = new SecretKey(macKeyBytes);
-
-		// get MAC data and calculate a new MAC with stored key
-		byte[] pubKey = localState.getRaw(PUBLIC_KEY);
-		byte[] ePubKey = localState.getRaw(E_PUBLIC_KEY);
-		BdfDictionary tp = localState.getDictionary(TRANSPORT);
-		long timestamp = localState.getLong(TIME);
-		BdfList toMacList = BdfList.of(pubKey, ePubKey, tp, timestamp);
-		byte[] toMac = clientHelper.toByteArray(toMacList);
-		byte[] calculatedMac = cryptoComponent.mac(MAC_LABEL, macKey, toMac);
-		if (!Arrays.equals(mac, calculatedMac)) {
-			LOG.warning("Received ACK with invalid MAC");
-			throw new GeneralSecurityException();
-		}
-	}
-
-	public void abort(Transaction txn, BdfDictionary state) {
-		IntroduceeEngine engine = new IntroduceeEngine();
-		BdfDictionary localAction = new BdfDictionary();
-		localAction.put(TYPE, TYPE_ABORT);
-		try {
-			processStateUpdate(txn, null,
-					engine.onLocalAction(state, localAction));
-		} catch (DbException | IOException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-	}
-
-	private BdfDictionary encodeTransportProperties(
-			Map<TransportId, TransportProperties> map) {
-
-		BdfDictionary d = new BdfDictionary();
-		for (Map.Entry<TransportId, TransportProperties> e : map.entrySet()) {
-			d.put(e.getKey().getString(), e.getValue());
-		}
-		return d;
-	}
-
-	private Map<TransportId, TransportProperties> parseTransportProperties(
-			BdfDictionary d) throws FormatException {
-
-		Map<TransportId, TransportProperties> tpMap = new HashMap<>();
-		BdfDictionary tpMapDict = d.getDictionary(TRANSPORT);
-		for (String key : tpMapDict.keySet()) {
-			TransportId transportId = new TransportId(key);
-			TransportProperties transportProperties = new TransportProperties();
-			BdfDictionary tpDict = tpMapDict.getDictionary(key);
-			for (String tkey : tpDict.keySet()) {
-				transportProperties.put(tkey, tpDict.getString(tkey));
-			}
-			tpMap.put(transportId, transportProperties);
-		}
-		return tpMap;
-	}
-
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeProtocolEngine.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..c0cf65a073da10600635754295ab449a335c1f39
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeProtocolEngine.java
@@ -0,0 +1,567 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.client.ContactGroupFactory;
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.contact.ContactManager;
+import org.briarproject.bramble.api.crypto.KeyPair;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.db.ContactExistsException;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.properties.TransportPropertyManager;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.api.transport.KeyManager;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.briar.api.client.MessageTracker;
+import org.briarproject.briar.api.client.ProtocolStateException;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.IntroductionRequest;
+import org.briarproject.briar.api.introduction.event.IntroductionAbortedEvent;
+import org.briarproject.briar.api.introduction.event.IntroductionRequestReceivedEvent;
+import org.briarproject.briar.api.introduction.event.IntroductionSucceededEvent;
+
+import java.security.GeneralSecurityException;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCEE;
+import static org.briarproject.briar.introduction.IntroduceeState.AWAIT_AUTH;
+import static org.briarproject.briar.introduction.IntroduceeState.AWAIT_RESPONSES;
+import static org.briarproject.briar.introduction.IntroduceeState.LOCAL_ACCEPTED;
+import static org.briarproject.briar.introduction.IntroduceeState.LOCAL_DECLINED;
+import static org.briarproject.briar.introduction.IntroduceeState.REMOTE_ACCEPTED;
+import static org.briarproject.briar.introduction.IntroduceeState.START;
+
+@Immutable
+@NotNullByDefault
+class IntroduceeProtocolEngine
+		extends AbstractProtocolEngine<IntroduceeSession> {
+
+	private final static Logger LOG =
+			Logger.getLogger(IntroduceeProtocolEngine.class.getSimpleName());
+
+	private final IntroductionCrypto crypto;
+	private final KeyManager keyManager;
+	private final TransportPropertyManager transportPropertyManager;
+
+	@Inject
+	IntroduceeProtocolEngine(
+			DatabaseComponent db,
+			ClientHelper clientHelper,
+			ContactManager contactManager,
+			ContactGroupFactory contactGroupFactory,
+			MessageTracker messageTracker,
+			IdentityManager identityManager,
+			MessageParser messageParser,
+			MessageEncoder messageEncoder,
+			Clock clock,
+			IntroductionCrypto crypto,
+			KeyManager keyManager,
+			TransportPropertyManager transportPropertyManager) {
+		super(db, clientHelper, contactManager, contactGroupFactory,
+				messageTracker, identityManager, messageParser, messageEncoder,
+				clock);
+		this.crypto = crypto;
+		this.keyManager = keyManager;
+		this.transportPropertyManager = transportPropertyManager;
+	}
+
+	@Override
+	public IntroduceeSession onRequestAction(Transaction txn,
+			IntroduceeSession session, @Nullable String message,
+			long timestamp) {
+		throw new UnsupportedOperationException(); // Invalid in this role
+	}
+
+	@Override
+	public IntroduceeSession onAcceptAction(Transaction txn,
+			IntroduceeSession session, long timestamp) throws DbException {
+		switch (session.getState()) {
+			case AWAIT_RESPONSES:
+			case REMOTE_ACCEPTED:
+				return onLocalAccept(txn, session, timestamp);
+			case START:
+			case LOCAL_DECLINED:
+			case LOCAL_ACCEPTED:
+			case AWAIT_AUTH:
+			case AWAIT_ACTIVATE:
+				throw new ProtocolStateException(); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onDeclineAction(Transaction txn,
+			IntroduceeSession session, long timestamp) throws DbException {
+		switch (session.getState()) {
+			case AWAIT_RESPONSES:
+			case REMOTE_ACCEPTED:
+				return onLocalDecline(txn, session, timestamp);
+			case START:
+			case LOCAL_DECLINED:
+			case LOCAL_ACCEPTED:
+			case AWAIT_AUTH:
+			case AWAIT_ACTIVATE:
+				throw new ProtocolStateException(); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onRequestMessage(Transaction txn,
+			IntroduceeSession session, RequestMessage m) throws DbException {
+		switch (session.getState()) {
+			case START:
+				return onRemoteRequest(txn, session, m);
+			case AWAIT_RESPONSES:
+			case LOCAL_DECLINED:
+			case LOCAL_ACCEPTED:
+			case REMOTE_ACCEPTED:
+			case AWAIT_AUTH:
+			case AWAIT_ACTIVATE:
+				return abort(txn, session); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onAcceptMessage(Transaction txn,
+			IntroduceeSession session, AcceptMessage m) throws DbException {
+		switch (session.getState()) {
+			case LOCAL_DECLINED:
+				return onRemoteResponseWhenDeclined(txn, session, m);
+			case AWAIT_RESPONSES:
+			case LOCAL_ACCEPTED:
+				return onRemoteAccept(txn, session, m);
+			case START:
+			case REMOTE_ACCEPTED:
+			case AWAIT_AUTH:
+			case AWAIT_ACTIVATE:
+				return abort(txn, session); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onDeclineMessage(Transaction txn,
+			IntroduceeSession session, DeclineMessage m) throws DbException {
+		switch (session.getState()) {
+			case LOCAL_DECLINED:
+				return onRemoteResponseWhenDeclined(txn, session, m);
+			case AWAIT_RESPONSES:
+			case LOCAL_ACCEPTED:
+				return onRemoteDecline(txn, session, m);
+			case START:
+			case REMOTE_ACCEPTED:
+			case AWAIT_AUTH:
+			case AWAIT_ACTIVATE:
+				return abort(txn, session); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onAuthMessage(Transaction txn,
+			IntroduceeSession session, AuthMessage m) throws DbException {
+		switch (session.getState()) {
+			case AWAIT_AUTH:
+				return onRemoteAuth(txn, session, m);
+			case START:
+			case AWAIT_RESPONSES:
+			case LOCAL_DECLINED:
+			case LOCAL_ACCEPTED:
+			case REMOTE_ACCEPTED:
+			case AWAIT_ACTIVATE:
+				return abort(txn, session); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onActivateMessage(Transaction txn,
+			IntroduceeSession session, ActivateMessage m) throws DbException {
+		switch (session.getState()) {
+			case AWAIT_ACTIVATE:
+				return onRemoteActivate(txn, session, m);
+			case START:
+			case AWAIT_RESPONSES:
+			case LOCAL_DECLINED:
+			case LOCAL_ACCEPTED:
+			case REMOTE_ACCEPTED:
+			case AWAIT_AUTH:
+				return abort(txn, session); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroduceeSession onAbortMessage(Transaction txn,
+			IntroduceeSession session, AbortMessage m) throws DbException {
+		return onRemoteAbort(txn, session, m);
+	}
+
+	private IntroduceeSession onRemoteRequest(Transaction txn,
+			IntroduceeSession s, RequestMessage m) throws DbException {
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+
+		// Mark the request visible in the UI and available to answer
+		markMessageVisibleInUi(txn, m.getMessageId());
+		markRequestAvailableToAnswer(txn, m.getMessageId(), true);
+
+		// Add SessionId to message metadata
+		addSessionId(txn, m.getMessageId(), s.getSessionId());
+
+		// Track the incoming message
+		messageTracker
+				.trackMessage(txn, m.getGroupId(), m.getTimestamp(), false);
+
+		// Broadcast IntroductionRequestReceivedEvent
+		LocalAuthor localAuthor = identityManager.getLocalAuthor(txn);
+		Contact c = contactManager.getContact(txn, s.getIntroducer().getId(),
+				localAuthor.getId());
+		boolean contactExists = contactManager
+				.contactExists(txn, m.getAuthor().getId(), localAuthor.getId());
+		IntroductionRequest request =
+				new IntroductionRequest(s.getSessionId(), m.getMessageId(),
+						m.getGroupId(), INTRODUCEE, m.getTimestamp(), false,
+						false, false, false, m.getAuthor().getName(), false,
+						m.getMessage(), false, contactExists);
+		IntroductionRequestReceivedEvent e =
+				new IntroductionRequestReceivedEvent(c.getId(), request);
+		txn.attach(e);
+
+		// Move to the AWAIT_RESPONSES state
+		return IntroduceeSession.addRemoteRequest(s, AWAIT_RESPONSES, m);
+	}
+
+	private IntroduceeSession onLocalAccept(Transaction txn,
+			IntroduceeSession s, long timestamp) throws DbException {
+		// Mark the request message unavailable to answer
+		markRequestsUnavailableToAnswer(txn, s);
+
+		// Create ephemeral key pair and get local transport properties
+		KeyPair keyPair = crypto.generateKeyPair();
+		byte[] publicKey = keyPair.getPublic().getEncoded();
+		byte[] privateKey = keyPair.getPrivate().getEncoded();
+		Map<TransportId, TransportProperties> transportProperties =
+				transportPropertyManager.getLocalProperties(txn);
+
+		// Send a ACCEPT message
+		long localTimestamp =
+				Math.max(timestamp + 1, getLocalTimestamp(s));
+		Message sent = sendAcceptMessage(txn, s, localTimestamp, publicKey,
+				localTimestamp, transportProperties, true);
+		// Track the message
+		messageTracker.trackOutgoingMessage(txn, sent);
+
+		// Determine the next state
+		IntroduceeState state =
+				s.getState() == AWAIT_RESPONSES ? LOCAL_ACCEPTED : AWAIT_AUTH;
+		IntroduceeSession sNew = IntroduceeSession
+				.addLocalAccept(s, state, sent, publicKey, privateKey,
+						localTimestamp, transportProperties);
+
+		if (state == AWAIT_AUTH) {
+			// Move to the AWAIT_AUTH state
+			return onLocalAuth(txn, sNew);
+		}
+		// Move to the LOCAL_ACCEPTED state
+		return sNew;
+	}
+
+	private IntroduceeSession onLocalDecline(Transaction txn,
+			IntroduceeSession s, long timestamp) throws DbException {
+		// Mark the request message unavailable to answer
+		markRequestsUnavailableToAnswer(txn, s);
+
+		// Send a DECLINE message
+		long localTimestamp = Math.max(timestamp + 1, getLocalTimestamp(s));
+		Message sent = sendDeclineMessage(txn, s, localTimestamp, true);
+
+		// Track the message
+		messageTracker.trackOutgoingMessage(txn, sent);
+
+		// Move to the START or LOCAL_DECLINED state, if still awaiting response
+		IntroduceeState state =
+				s.getState() == REMOTE_ACCEPTED ? START : LOCAL_DECLINED;
+		return IntroduceeSession
+				.clear(s, state, sent.getId(), sent.getTimestamp(),
+						s.getLastRemoteMessageId());
+	}
+
+	private IntroduceeSession onRemoteAccept(Transaction txn,
+			IntroduceeSession s, AcceptMessage m)
+			throws DbException {
+		// The timestamp must be higher than the last request message
+		if (m.getTimestamp() <= s.getRequestTimestamp())
+			return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+
+		// Determine next state
+		IntroduceeState state =
+				s.getState() == AWAIT_RESPONSES ? REMOTE_ACCEPTED : AWAIT_AUTH;
+
+		if (state == AWAIT_AUTH) {
+			// Move to the AWAIT_AUTH state and send own auth message
+			return onLocalAuth(txn,
+					IntroduceeSession.addRemoteAccept(s, AWAIT_AUTH, m));
+		}
+		// Move to the REMOTE_ACCEPTED state
+		return IntroduceeSession.addRemoteAccept(s, state, m);
+	}
+
+	private IntroduceeSession onRemoteDecline(Transaction txn,
+			IntroduceeSession s, DeclineMessage m) throws DbException {
+		// The timestamp must be higher than the last request message
+		if (m.getTimestamp() <= s.getRequestTimestamp())
+			return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+
+		// Mark the response visible in the UI
+		markMessageVisibleInUi(txn, m.getMessageId());
+
+		// Track the incoming message
+		messageTracker
+				.trackMessage(txn, m.getGroupId(), m.getTimestamp(), false);
+
+		// Broadcast IntroductionResponseReceivedEvent
+		broadcastIntroductionResponseReceivedEvent(txn, s,
+				s.getIntroducer().getId(), m);
+
+		// Move back to START state
+		return IntroduceeSession.clear(s, START, s.getLastLocalMessageId(),
+				s.getLocalTimestamp(), m.getMessageId());
+	}
+
+	private IntroduceeSession onRemoteResponseWhenDeclined(Transaction txn,
+			IntroduceeSession s, AbstractIntroductionMessage m)
+			throws DbException {
+		// The timestamp must be higher than the last request message
+		if (m.getTimestamp() <= s.getRequestTimestamp())
+			return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+
+		// Move to START state
+		return IntroduceeSession.clear(s, START, s.getLastLocalMessageId(),
+				s.getLocalTimestamp(), m.getMessageId());
+	}
+
+	private IntroduceeSession onLocalAuth(Transaction txn, IntroduceeSession s)
+			throws DbException {
+		byte[] mac;
+		byte[] signature;
+		SecretKey masterKey, aliceMacKey, bobMacKey;
+		try {
+			masterKey = crypto.deriveMasterKey(s);
+			aliceMacKey = crypto.deriveMacKey(masterKey, true);
+			bobMacKey = crypto.deriveMacKey(masterKey, false);
+			SecretKey ourMacKey = s.getLocal().alice ? aliceMacKey : bobMacKey;
+			LocalAuthor localAuthor = identityManager.getLocalAuthor(txn);
+			mac = crypto.authMac(ourMacKey, s, localAuthor.getId());
+			signature = crypto.sign(ourMacKey, localAuthor.getPrivateKey());
+		} catch (GeneralSecurityException e) {
+			if (LOG.isLoggable(WARNING))
+				LOG.log(WARNING, e.toString(), e);
+			return abort(txn, s);
+		}
+		if (s.getState() != AWAIT_AUTH) throw new AssertionError();
+		Message sent = sendAuthMessage(txn, s, getLocalTimestamp(s), mac,
+				signature);
+		return IntroduceeSession.addLocalAuth(s, AWAIT_AUTH, sent, masterKey,
+				aliceMacKey, bobMacKey);
+	}
+
+	private IntroduceeSession onRemoteAuth(Transaction txn,
+			IntroduceeSession s, AuthMessage m) throws DbException {
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+
+		LocalAuthor localAuthor = identityManager.getLocalAuthor(txn);
+		try {
+			crypto.verifyAuthMac(m.getMac(), s, localAuthor.getId());
+			crypto.verifySignature(m.getSignature(), s);
+		} catch (GeneralSecurityException e) {
+			return abort(txn, s);
+		}
+		long timestamp = Math.min(s.getLocal().acceptTimestamp,
+				s.getRemote().acceptTimestamp);
+		if (timestamp == -1) throw new AssertionError();
+
+		Map<TransportId, KeySetId> keys = null;
+		try {
+			contactManager
+					.addContact(txn, s.getRemote().author, localAuthor.getId(),
+							false, true);
+
+			// Only add transport properties and keys when the contact was added
+			// This will be changed once we have a way to reset state for peers
+			// that were contacts already at some point in the past.
+			Contact c = contactManager
+					.getContact(txn, s.getRemote().author.getId(),
+							localAuthor.getId());
+
+			// bind the keys to the new contact
+			//noinspection ConstantConditions
+			keys = keyManager
+					.addUnboundKeys(txn, new SecretKey(s.getMasterKey()),
+							timestamp, s.getRemote().alice);
+			keyManager.bindKeys(txn, c.getId(), keys);
+
+			// add signed transport properties for the contact
+			//noinspection ConstantConditions
+			transportPropertyManager.addRemoteProperties(txn, c.getId(),
+					s.getRemote().transportProperties);
+
+			// Broadcast IntroductionSucceededEvent, because contact got added
+			IntroductionSucceededEvent e = new IntroductionSucceededEvent(c);
+			txn.attach(e);
+		} catch (ContactExistsException e) {
+			// Ignore this, because the other introducee might have deleted us.
+			// So we still want updated transport properties
+			// and new transport keys.
+		}
+
+		// send ACTIVATE message with a MAC
+		byte[] mac = crypto.activateMac(s);
+		Message sent = sendActivateMessage(txn, s, getLocalTimestamp(s), mac);
+
+		// Move to AWAIT_ACTIVATE state and clear key material from session
+		return IntroduceeSession.awaitActivate(s, m, sent, keys);
+	}
+
+	private IntroduceeSession onRemoteActivate(Transaction txn,
+			IntroduceeSession s, ActivateMessage m) throws DbException {
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+
+		// Validate MAC
+		try {
+			crypto.verifyActivateMac(m.getMac(), s);
+		} catch (GeneralSecurityException e) {
+			return abort(txn, s);
+		}
+
+		// We might not have added transport keys
+		// if the contact existed when the remote AUTH was received.
+		if (s.getTransportKeys() != null) {
+			// Activate transport keys
+			keyManager.activateKeys(txn, s.getTransportKeys());
+		}
+
+		// Move back to START state
+		return IntroduceeSession.clear(s, START, s.getLastLocalMessageId(),
+				s.getLocalTimestamp(), m.getMessageId());
+	}
+
+	private IntroduceeSession onRemoteAbort(Transaction txn,
+			IntroduceeSession s, AbortMessage m)
+			throws DbException {
+		// Mark the request message unavailable to answer
+		markRequestsUnavailableToAnswer(txn, s);
+
+		// Broadcast abort event for testing
+		txn.attach(new IntroductionAbortedEvent(s.getSessionId()));
+
+		// Reset the session back to initial state
+		return IntroduceeSession.clear(s, START, s.getLastLocalMessageId(),
+				s.getLocalTimestamp(), m.getMessageId());
+	}
+
+	private IntroduceeSession abort(Transaction txn, IntroduceeSession s)
+			throws DbException {
+		// Mark the request message unavailable to answer
+		markRequestsUnavailableToAnswer(txn, s);
+
+		// Send an ABORT message
+		Message sent = sendAbortMessage(txn, s, getLocalTimestamp(s));
+
+		// Broadcast abort event for testing
+		txn.attach(new IntroductionAbortedEvent(s.getSessionId()));
+
+		// Reset the session back to initial state
+		return IntroduceeSession
+				.clear(s, START, sent.getId(), sent.getTimestamp(),
+						s.getLastRemoteMessageId());
+	}
+
+	private boolean isInvalidDependency(IntroduceeSession s,
+			@Nullable MessageId dependency) {
+		return isInvalidDependency(s.getLastRemoteMessageId(), dependency);
+	}
+
+	private long getLocalTimestamp(IntroduceeSession s) {
+		return getLocalTimestamp(s.getLocalTimestamp(),
+				s.getRequestTimestamp());
+	}
+
+	private void addSessionId(Transaction txn, MessageId m, SessionId sessionId)
+			throws DbException {
+		BdfDictionary meta = new BdfDictionary();
+		messageEncoder.addSessionId(meta, sessionId);
+		try {
+			clientHelper.mergeMessageMetadata(txn, m, meta);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	private void markRequestsUnavailableToAnswer(Transaction txn,
+			IntroduceeSession s) throws DbException {
+		BdfDictionary query = messageParser
+				.getRequestsAvailableToAnswerQuery(s.getSessionId());
+		try {
+			Map<MessageId, BdfDictionary> results =
+					clientHelper.getMessageMetadataAsDictionary(txn,
+							s.getContactGroupId(), query);
+			for (MessageId m : results.keySet())
+				markRequestAvailableToAnswer(txn, m, false);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	private void markRequestAvailableToAnswer(Transaction txn, MessageId m,
+			boolean available) throws DbException {
+		BdfDictionary meta = new BdfDictionary();
+		messageEncoder.setAvailableToAnswer(meta, available);
+		try {
+			clientHelper.mergeMessageMetadata(txn, m, meta);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeSession.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeSession.java
new file mode 100644
index 0000000000000000000000000000000000000000..b952b3dc52e9c64a5c8900cb6e5c27131e309cc5
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeSession.java
@@ -0,0 +1,255 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.Role;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.briar.introduction.IntroduceeState.AWAIT_ACTIVATE;
+import static org.briarproject.briar.introduction.IntroduceeState.START;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCEE;
+
+@Immutable
+@NotNullByDefault
+class IntroduceeSession extends Session<IntroduceeState>
+		implements PeerSession {
+
+	private final GroupId contactGroupId;
+	private final Author introducer;
+	private final Local local;
+	private final Remote remote;
+	@Nullable
+	private final byte[] masterKey;
+	@Nullable
+	private final Map<TransportId, KeySetId> transportKeys;
+
+	IntroduceeSession(SessionId sessionId, IntroduceeState state,
+			long requestTimestamp, GroupId contactGroupId, Author introducer,
+			Local local, Remote remote, @Nullable byte[] masterKey,
+			@Nullable Map<TransportId, KeySetId> transportKeys) {
+		super(sessionId, state, requestTimestamp);
+		this.contactGroupId = contactGroupId;
+		this.introducer = introducer;
+		this.local = local;
+		this.remote = remote;
+		this.masterKey = masterKey;
+		this.transportKeys = transportKeys;
+	}
+
+	static IntroduceeSession getInitial(GroupId contactGroupId,
+			SessionId sessionId, Author introducer, boolean localIsAlice,
+			Author remoteAuthor) {
+		Local local =
+				new Local(localIsAlice, null, -1, null, null, null, -1, null);
+		Remote remote =
+				new Remote(!localIsAlice, remoteAuthor, null, null, null, -1,
+						null);
+		return new IntroduceeSession(sessionId, START, -1, contactGroupId,
+				introducer, local, remote, null, null);
+	}
+
+	static IntroduceeSession addRemoteRequest(IntroduceeSession s,
+			IntroduceeState state, RequestMessage m) {
+		Remote remote = new Remote(s.remote, m.getMessageId());
+		return new IntroduceeSession(s.getSessionId(), state, m.getTimestamp(),
+				s.contactGroupId, s.introducer, s.local, remote, s.masterKey,
+				s.transportKeys);
+	}
+
+	static IntroduceeSession addLocalAccept(IntroduceeSession s,
+			IntroduceeState state, Message acceptMessage,
+			byte[] ephemeralPublicKey, byte[] ephemeralPrivateKey,
+			long acceptTimestamp,
+			Map<TransportId, TransportProperties> transportProperties) {
+		Local local = new Local(s.local.alice, acceptMessage.getId(),
+				acceptMessage.getTimestamp(), ephemeralPublicKey,
+				ephemeralPrivateKey, transportProperties, acceptTimestamp,
+				null);
+		return new IntroduceeSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), s.contactGroupId, s.introducer, local,
+				s.remote, s.masterKey, s.transportKeys);
+	}
+
+	static IntroduceeSession addRemoteAccept(IntroduceeSession s,
+			IntroduceeState state, AcceptMessage m) {
+		Remote remote =
+				new Remote(s.remote.alice, s.remote.author, m.getMessageId(),
+						m.getEphemeralPublicKey(), m.getTransportProperties(),
+						m.getAcceptTimestamp(), s.remote.macKey);
+		return new IntroduceeSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), s.contactGroupId, s.introducer,
+				s.local, remote, s.masterKey, s.transportKeys);
+	}
+
+	static IntroduceeSession addLocalAuth(IntroduceeSession s,
+			IntroduceeState state, Message m, SecretKey masterKey,
+			SecretKey aliceMacKey, SecretKey bobMacKey) {
+		// add mac key and sent message
+		Local local = new Local(s.local.alice, m.getId(), m.getTimestamp(),
+				s.local.ephemeralPublicKey, s.local.ephemeralPrivateKey,
+				s.local.transportProperties, s.local.acceptTimestamp,
+				s.local.alice ? aliceMacKey.getBytes() : bobMacKey.getBytes());
+		// just add the mac key
+		Remote remote = new Remote(s.remote.alice, s.remote.author,
+				s.remote.lastMessageId, s.remote.ephemeralPublicKey,
+				s.remote.transportProperties, s.remote.acceptTimestamp,
+				s.remote.alice ? aliceMacKey.getBytes() : bobMacKey.getBytes());
+		// add master key
+		return new IntroduceeSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), s.contactGroupId, s.introducer, local,
+				remote, masterKey.getBytes(), s.transportKeys);
+	}
+
+	static IntroduceeSession awaitActivate(IntroduceeSession s, AuthMessage m,
+			Message sent, @Nullable Map<TransportId, KeySetId> transportKeys) {
+		Local local = new Local(s.local, sent.getId(), sent.getTimestamp());
+		Remote remote = new Remote(s.remote, m.getMessageId());
+		return new IntroduceeSession(s.getSessionId(), AWAIT_ACTIVATE,
+				s.getRequestTimestamp(), s.contactGroupId, s.introducer, local,
+				remote, null, transportKeys);
+	}
+
+	static IntroduceeSession clear(IntroduceeSession s, IntroduceeState state,
+			@Nullable MessageId lastLocalMessageId, long localTimestamp,
+			@Nullable MessageId lastRemoteMessageId) {
+		Local local =
+				new Local(s.local.alice, lastLocalMessageId, localTimestamp,
+						null, null, null, -1, null);
+		Remote remote =
+				new Remote(s.remote.alice, s.remote.author, lastRemoteMessageId,
+						null, null, -1, null);
+		return new IntroduceeSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), s.contactGroupId, s.introducer, local,
+				remote, null, null);
+	}
+
+	@Override
+	Role getRole() {
+		return INTRODUCEE;
+	}
+
+	@Override
+	public GroupId getContactGroupId() {
+		return contactGroupId;
+	}
+
+	@Override
+	public long getLocalTimestamp() {
+		return local.lastMessageTimestamp;
+	}
+
+	@Nullable
+	@Override
+	public MessageId getLastLocalMessageId() {
+		return local.lastMessageId;
+	}
+
+	@Nullable
+	@Override
+	public MessageId getLastRemoteMessageId() {
+		return remote.lastMessageId;
+	}
+
+	Author getIntroducer() {
+		return introducer;
+	}
+
+	public Local getLocal() {
+		return local;
+	}
+
+	public Remote getRemote() {
+		return remote;
+	}
+
+	@Nullable
+	byte[] getMasterKey() {
+		return masterKey;
+	}
+
+	@Nullable
+	Map<TransportId, KeySetId> getTransportKeys() {
+		return transportKeys;
+	}
+
+	abstract static class Common {
+		final boolean alice;
+		@Nullable
+		final MessageId lastMessageId;
+		@Nullable
+		final byte[] ephemeralPublicKey;
+		@Nullable
+		final Map<TransportId, TransportProperties> transportProperties;
+		final long acceptTimestamp;
+		@Nullable
+		final byte[] macKey;
+
+		private Common(boolean alice, @Nullable MessageId lastMessageId,
+				@Nullable byte[] ephemeralPublicKey, @Nullable
+				Map<TransportId, TransportProperties> transportProperties,
+				long acceptTimestamp, @Nullable byte[] macKey) {
+			this.alice = alice;
+			this.lastMessageId = lastMessageId;
+			this.ephemeralPublicKey = ephemeralPublicKey;
+			this.transportProperties = transportProperties;
+			this.acceptTimestamp = acceptTimestamp;
+			this.macKey = macKey;
+		}
+	}
+
+	static class Local extends Common {
+		final long lastMessageTimestamp;
+		@Nullable
+		final byte[] ephemeralPrivateKey;
+
+		Local(boolean alice, @Nullable MessageId lastMessageId,
+				long lastMessageTimestamp, @Nullable byte[] ephemeralPublicKey,
+				@Nullable byte[] ephemeralPrivateKey, @Nullable
+				Map<TransportId, TransportProperties> transportProperties,
+				long acceptTimestamp, @Nullable byte[] macKey) {
+			super(alice, lastMessageId, ephemeralPublicKey, transportProperties,
+					acceptTimestamp, macKey);
+			this.lastMessageTimestamp = lastMessageTimestamp;
+			this.ephemeralPrivateKey = ephemeralPrivateKey;
+		}
+
+		private Local(Local s, @Nullable MessageId lastMessageId,
+				long lastMessageTimestamp) {
+			this(s.alice, lastMessageId, lastMessageTimestamp,
+					s.ephemeralPublicKey, s.ephemeralPrivateKey,
+					s.transportProperties, s.acceptTimestamp, s.macKey);
+		}
+	}
+
+	static class Remote extends Common {
+		final Author author;
+
+		Remote(boolean alice, Author author,
+				@Nullable MessageId lastMessageId,
+				@Nullable byte[] ephemeralPublicKey, @Nullable
+				Map<TransportId, TransportProperties> transportProperties,
+				long acceptTimestamp, @Nullable byte[] macKey) {
+			super(alice, lastMessageId, ephemeralPublicKey, transportProperties,
+					acceptTimestamp, macKey);
+			this.author = author;
+		}
+
+		private Remote(Remote s, @Nullable MessageId lastMessageId) {
+			this(s.alice, s.author, lastMessageId, s.ephemeralPublicKey,
+					s.transportProperties, s.acceptTimestamp, s.macKey);
+		}
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeState.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeState.java
new file mode 100644
index 0000000000000000000000000000000000000000..7a54abde8d0938a4201dd307013264431cc2e3ef
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroduceeState.java
@@ -0,0 +1,36 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+enum IntroduceeState implements State {
+
+	START(0),
+	AWAIT_RESPONSES(1),
+	LOCAL_DECLINED(2),
+	LOCAL_ACCEPTED(3),
+	REMOTE_ACCEPTED(4),
+	AWAIT_AUTH(5),
+	AWAIT_ACTIVATE(6);
+
+	private final int value;
+
+	IntroduceeState(int value) {
+		this.value = value;
+	}
+
+	@Override
+	public int getValue() {
+		return value;
+	}
+
+	static IntroduceeState fromValue(int value) throws FormatException {
+		for (IntroduceeState s : values()) if (s.value == value) return s;
+		throw new FormatException();
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerEngine.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerEngine.java
deleted file mode 100644
index df364b34ff0f7cdc39e4c607678ae6cdb1e64398..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerEngine.java
+++ /dev/null
@@ -1,370 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.identity.AuthorId;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.briar.api.client.ProtocolEngine;
-import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.api.introduction.IntroducerAction;
-import org.briarproject.briar.api.introduction.IntroducerProtocolState;
-import org.briarproject.briar.api.introduction.IntroductionResponse;
-import org.briarproject.briar.api.introduction.event.IntroductionAbortedEvent;
-import org.briarproject.briar.api.introduction.event.IntroductionResponseReceivedEvent;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.logging.Logger;
-
-import javax.annotation.concurrent.Immutable;
-
-import static java.util.logging.Level.INFO;
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.briar.api.introduction.IntroducerAction.LOCAL_ABORT;
-import static org.briarproject.briar.api.introduction.IntroducerAction.LOCAL_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_ACCEPT_1;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_ACCEPT_2;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_DECLINE_1;
-import static org.briarproject.briar.api.introduction.IntroducerAction.REMOTE_DECLINE_2;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_ACKS;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_ACK_1;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_ACK_2;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_RESPONSES;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_RESPONSE_1;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_RESPONSE_2;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.ERROR;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.FINISHED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.RESPONSE_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.RESPONSE_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-
-@Immutable
-@NotNullByDefault
-class IntroducerEngine
-		implements ProtocolEngine<BdfDictionary, BdfDictionary, BdfDictionary> {
-
-	private static final Logger LOG =
-			Logger.getLogger(IntroducerEngine.class.getName());
-
-	@Override
-	public StateUpdate<BdfDictionary, BdfDictionary> onLocalAction(
-			BdfDictionary localState, BdfDictionary localAction) {
-
-		try {
-			IntroducerProtocolState currentState =
-					getState(localState.getLong(STATE));
-			int type = localAction.getLong(TYPE).intValue();
-			IntroducerAction action = IntroducerAction.getLocal(type);
-			IntroducerProtocolState nextState = currentState.next(action);
-
-			if (action == LOCAL_ABORT && currentState != ERROR) {
-				return abortSession(currentState, localState);
-			}
-
-			if (nextState == ERROR) {
-				if (LOG.isLoggable(WARNING)) {
-					LOG.warning("Error: Invalid action in state " +
-							currentState.name());
-				}
-				return noUpdate(localState);
-			}
-
-			localState.put(STATE, nextState.getValue());
-			if (action == LOCAL_REQUEST) {
-				// create the introduction requests for both contacts
-				List<BdfDictionary> messages = new ArrayList<>(2);
-				BdfDictionary msg1 = new BdfDictionary();
-				msg1.put(TYPE, TYPE_REQUEST);
-				msg1.put(SESSION_ID, localState.getRaw(SESSION_ID));
-				msg1.put(GROUP_ID, localState.getRaw(GROUP_ID_1));
-				msg1.put(NAME, localState.getString(CONTACT_2));
-				msg1.put(PUBLIC_KEY, localAction.getRaw(PUBLIC_KEY2));
-				if (localAction.containsKey(MSG)) {
-					msg1.put(MSG, localAction.getString(MSG));
-				}
-				msg1.put(MESSAGE_TIME, localAction.getLong(MESSAGE_TIME));
-				messages.add(msg1);
-				logLocalAction(currentState, localState);
-				BdfDictionary msg2 = new BdfDictionary();
-				msg2.put(TYPE, TYPE_REQUEST);
-				msg2.put(SESSION_ID, localState.getRaw(SESSION_ID));
-				msg2.put(GROUP_ID, localState.getRaw(GROUP_ID_2));
-				msg2.put(NAME, localState.getString(CONTACT_1));
-				msg2.put(PUBLIC_KEY, localAction.getRaw(PUBLIC_KEY1));
-				if (localAction.containsKey(MSG)) {
-					msg2.put(MSG, localAction.getString(MSG));
-				}
-				msg2.put(MESSAGE_TIME, localAction.getLong(MESSAGE_TIME));
-				messages.add(msg2);
-				logLocalAction(currentState, localState);
-
-				List<Event> events = Collections.emptyList();
-				return new StateUpdate<>(false, false,
-						localState, messages, events);
-			} else {
-				throw new IllegalArgumentException("Unknown Local Action");
-			}
-		} catch (FormatException e) {
-			throw new IllegalArgumentException(e);
-		}
-	}
-
-	@Override
-	public StateUpdate<BdfDictionary, BdfDictionary> onMessageReceived(
-			BdfDictionary localState, BdfDictionary msg) {
-
-		try {
-			IntroducerProtocolState currentState =
-					getState(localState.getLong(STATE));
-			int type = msg.getLong(TYPE).intValue();
-			boolean one = isContact1(localState, msg);
-			IntroducerAction action = IntroducerAction.getRemote(type, one);
-			IntroducerProtocolState nextState = currentState.next(action);
-
-			logMessageReceived(currentState, nextState, localState, type, msg);
-
-			if (nextState == ERROR) {
-				if (currentState != ERROR) {
-					return abortSession(currentState, localState);
-				} else {
-					return noUpdate(localState);
-				}
-			}
-
-			List<BdfDictionary> messages;
-			List<Event> events;
-
-			// we have sent our requests and just got the 1st or 2nd response
-			if (currentState == AWAIT_RESPONSES ||
-					currentState == AWAIT_RESPONSE_1 ||
-					currentState == AWAIT_RESPONSE_2) {
-				// update next state based on message content
-				action = IntroducerAction
-						.getRemote(type, one, msg.getBoolean(ACCEPT));
-				nextState = currentState.next(action);
-				localState.put(STATE, nextState.getValue());
-				if (one) localState.put(RESPONSE_1, msg.getRaw(MESSAGE_ID));
-				else localState.put(RESPONSE_2, msg.getRaw(MESSAGE_ID));
-
-				messages = forwardMessage(localState, msg);
-				events = Collections.singletonList(getEvent(localState, msg));
-			}
-			// we have forwarded both responses and now received the 1st or 2nd ACK
-			else if (currentState == AWAIT_ACKS ||
-					currentState == AWAIT_ACK_1 ||
-					currentState == AWAIT_ACK_2) {
-				localState.put(STATE, nextState.getValue());
-				messages = forwardMessage(localState, msg);
-				events = Collections.emptyList();
-			}
-			// we probably received a response while already being FINISHED
-			else if (currentState == FINISHED) {
-				// if it was a response store it to be found later
-				if (action == REMOTE_ACCEPT_1 || action == REMOTE_DECLINE_1) {
-					localState.put(RESPONSE_1, msg.getRaw(MESSAGE_ID));
-					messages = Collections.emptyList();
-					events = Collections
-							.singletonList(getEvent(localState, msg));
-				} else if (action == REMOTE_ACCEPT_2 ||
-						action == REMOTE_DECLINE_2) {
-					localState.put(RESPONSE_2, msg.getRaw(MESSAGE_ID));
-					messages = Collections.emptyList();
-					events = Collections
-							.singletonList(getEvent(localState, msg));
-				} else return noUpdate(localState);
-			} else {
-				throw new IllegalArgumentException("Bad state");
-			}
-			return new StateUpdate<>(false, false,
-					localState, messages, events);
-		} catch (FormatException e) {
-			throw new IllegalArgumentException(e);
-		}
-	}
-
-	private void logLocalAction(IntroducerProtocolState state,
-			BdfDictionary localState) {
-
-		if (!LOG.isLoggable(INFO)) return;
-		try {
-			LOG.info("Sending introduction request in state " + state.name());
-			LOG.info("Moving on to state " +
-					getState(localState.getLong(STATE)).name());
-		} catch (FormatException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-	}
-
-	private void logMessageReceived(IntroducerProtocolState currentState,
-			IntroducerProtocolState nextState,
-			BdfDictionary localState, int type, BdfDictionary msg) {
-
-		if (!LOG.isLoggable(INFO)) return;
-
-		String t = "unknown";
-		if (type == TYPE_REQUEST) t = "Introduction";
-		else if (type == TYPE_RESPONSE) t = "Response";
-		else if (type == TYPE_ACK) t = "ACK";
-		else if (type == TYPE_ABORT) t = "Abort";
-
-		LOG.info("Received " + t + " in state " + currentState.name());
-		LOG.info("Moving on to state " + nextState.name());
-	}
-
-	private List<BdfDictionary> forwardMessage(BdfDictionary localState,
-			BdfDictionary message) throws FormatException {
-
-		// clone the message here, because we still need the original
-		BdfDictionary msg = (BdfDictionary) message.clone();
-		if (isContact1(localState, msg)) {
-			msg.put(GROUP_ID, localState.getRaw(GROUP_ID_2));
-		} else {
-			msg.put(GROUP_ID, localState.getRaw(GROUP_ID_1));
-		}
-
-		return Collections.singletonList(msg);
-	}
-
-	@Override
-	public StateUpdate<BdfDictionary, BdfDictionary> onMessageDelivered(
-			BdfDictionary localState, BdfDictionary delivered) {
-		try {
-			return noUpdate(localState);
-		} catch (FormatException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-			return null;
-		}
-	}
-
-	private IntroducerProtocolState getState(Long state) {
-		return IntroducerProtocolState.fromValue(state.intValue());
-	}
-
-	private Event getEvent(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		ContactId contactId =
-				new ContactId(localState.getLong(CONTACT_ID_1).intValue());
-		AuthorId authorId = new AuthorId(localState.getRaw(AUTHOR_ID_1));
-		if (Arrays
-				.equals(msg.getRaw(GROUP_ID), localState.getRaw(GROUP_ID_2))) {
-			contactId =
-					new ContactId(localState.getLong(CONTACT_ID_2).intValue());
-			authorId = new AuthorId(localState.getRaw(AUTHOR_ID_2));
-		}
-
-		SessionId sessionId = new SessionId(localState.getRaw(SESSION_ID));
-		MessageId messageId = new MessageId(msg.getRaw(MESSAGE_ID));
-		GroupId groupId = new GroupId(msg.getRaw(GROUP_ID));
-		long time = msg.getLong(MESSAGE_TIME);
-		String name = getOtherContact(localState, msg);
-		boolean accept = msg.getBoolean(ACCEPT);
-
-		IntroductionResponse ir =
-				new IntroductionResponse(sessionId, messageId, groupId,
-						ROLE_INTRODUCER, time, false, false, false, false,
-						authorId, name, accept);
-		return new IntroductionResponseReceivedEvent(contactId, ir);
-	}
-
-	private boolean isContact1(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		byte[] group = msg.getRaw(GROUP_ID);
-		byte[] group1 = localState.getRaw(GROUP_ID_1);
-		byte[] group2 = localState.getRaw(GROUP_ID_2);
-
-		if (Arrays.equals(group, group1)) {
-			return true;
-		} else if (Arrays.equals(group, group2)) {
-			return false;
-		} else {
-			throw new FormatException();
-		}
-	}
-
-	private String getOtherContact(BdfDictionary localState, BdfDictionary msg)
-			throws FormatException {
-
-		String to = localState.getString(CONTACT_2);
-		if (Arrays
-				.equals(msg.getRaw(GROUP_ID), localState.getRaw(GROUP_ID_2))) {
-			to = localState.getString(CONTACT_1);
-		}
-		return to;
-	}
-
-	private StateUpdate<BdfDictionary, BdfDictionary> abortSession(
-			IntroducerProtocolState currentState, BdfDictionary localState)
-			throws FormatException {
-
-		if (LOG.isLoggable(WARNING))
-			LOG.warning("Aborting protocol session in state " +
-					currentState.name());
-
-		localState.put(STATE, ERROR.getValue());
-		List<BdfDictionary> messages = new ArrayList<>(2);
-		BdfDictionary msg1 = new BdfDictionary();
-		msg1.put(TYPE, TYPE_ABORT);
-		msg1.put(SESSION_ID, localState.getRaw(SESSION_ID));
-		msg1.put(GROUP_ID, localState.getRaw(GROUP_ID_1));
-		messages.add(msg1);
-		BdfDictionary msg2 = new BdfDictionary();
-		msg2.put(TYPE, TYPE_ABORT);
-		msg2.put(SESSION_ID, localState.getRaw(SESSION_ID));
-		msg2.put(GROUP_ID, localState.getRaw(GROUP_ID_2));
-		messages.add(msg2);
-
-		// send one abort event per contact
-		List<Event> events = new ArrayList<>(2);
-		SessionId sessionId = new SessionId(localState.getRaw(SESSION_ID));
-		ContactId contactId1 =
-				new ContactId(localState.getLong(CONTACT_ID_1).intValue());
-		ContactId contactId2 =
-				new ContactId(localState.getLong(CONTACT_ID_2).intValue());
-		Event event1 = new IntroductionAbortedEvent(contactId1, sessionId);
-		events.add(event1);
-		Event event2 = new IntroductionAbortedEvent(contactId2, sessionId);
-		events.add(event2);
-
-		return new StateUpdate<>(false, false, localState, messages, events);
-	}
-
-	private StateUpdate<BdfDictionary, BdfDictionary> noUpdate(
-			BdfDictionary localState) throws FormatException {
-
-		return new StateUpdate<>(false, false, localState,
-				Collections.<BdfDictionary>emptyList(),
-				Collections.emptyList());
-	}
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerManager.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerManager.java
deleted file mode 100644
index b24109396c7f634c8668ff9aea64dca16750cca8..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerManager.java
+++ /dev/null
@@ -1,181 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.Bytes;
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.contact.Contact;
-import org.briarproject.bramble.api.crypto.CryptoComponent;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.bramble.util.StringUtils;
-
-import java.io.IOException;
-import java.util.logging.Logger;
-
-import javax.annotation.Nullable;
-import javax.annotation.concurrent.Immutable;
-import javax.inject.Inject;
-
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.PREPARE_REQUESTS;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_INTRODUCTION_MESSAGE_LENGTH;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STORAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-
-@Immutable
-@NotNullByDefault
-class IntroducerManager {
-
-	private static final Logger LOG =
-			Logger.getLogger(IntroducerManager.class.getName());
-
-	private final MessageSender messageSender;
-	private final ClientHelper clientHelper;
-	private final Clock clock;
-	private final CryptoComponent cryptoComponent;
-	private final IntroductionGroupFactory introductionGroupFactory;
-
-	@Inject
-	IntroducerManager(MessageSender messageSender, ClientHelper clientHelper,
-			Clock clock, CryptoComponent cryptoComponent,
-			IntroductionGroupFactory introductionGroupFactory) {
-
-		this.messageSender = messageSender;
-		this.clientHelper = clientHelper;
-		this.clock = clock;
-		this.cryptoComponent = cryptoComponent;
-		this.introductionGroupFactory = introductionGroupFactory;
-	}
-
-	public BdfDictionary initialize(Transaction txn, Contact c1, Contact c2)
-			throws FormatException, DbException {
-
-		// create local message to keep engine state
-		long now = clock.currentTimeMillis();
-		Bytes salt = new Bytes(new byte[64]);
-		cryptoComponent.getSecureRandom().nextBytes(salt.getBytes());
-
-		Message m = clientHelper.createMessage(
-				introductionGroupFactory.createLocalGroup().getId(), now,
-				BdfList.of(salt));
-		MessageId sessionId = m.getId();
-
-		Group g1 = introductionGroupFactory.createIntroductionGroup(c1);
-		Group g2 = introductionGroupFactory.createIntroductionGroup(c2);
-
-		BdfDictionary d = new BdfDictionary();
-		d.put(SESSION_ID, sessionId);
-		d.put(STORAGE_ID, sessionId);
-		d.put(STATE, PREPARE_REQUESTS.getValue());
-		d.put(ROLE, ROLE_INTRODUCER);
-		d.put(GROUP_ID_1, g1.getId());
-		d.put(GROUP_ID_2, g2.getId());
-		d.put(CONTACT_1, c1.getAuthor().getName());
-		d.put(CONTACT_2, c2.getAuthor().getName());
-		d.put(CONTACT_ID_1, c1.getId().getInt());
-		d.put(CONTACT_ID_2, c2.getId().getInt());
-		d.put(AUTHOR_ID_1, c1.getAuthor().getId());
-		d.put(AUTHOR_ID_2, c2.getAuthor().getId());
-
-		// save local state to database
-		clientHelper.addLocalMessage(txn, m, d, false);
-
-		return d;
-	}
-
-	void makeIntroduction(Transaction txn, Contact c1, Contact c2,
-			@Nullable String msg, long timestamp)
-			throws DbException, FormatException {
-
-		// TODO check for existing session with those contacts?
-		//      deny new introduction under which conditions?
-
-		// initialize engine state
-		BdfDictionary localState = initialize(txn, c1, c2);
-
-		// define action
-		BdfDictionary localAction = new BdfDictionary();
-		localAction.put(TYPE, TYPE_REQUEST);
-		if (!StringUtils.isNullOrEmpty(msg)) {
-			int msgLength = StringUtils.toUtf8(msg).length;
-			if (msgLength > MAX_INTRODUCTION_MESSAGE_LENGTH)
-				throw new IllegalArgumentException();
-			localAction.put(MSG, msg);
-		}
-		localAction.put(PUBLIC_KEY1, c1.getAuthor().getPublicKey());
-		localAction.put(PUBLIC_KEY2, c2.getAuthor().getPublicKey());
-		localAction.put(MESSAGE_TIME, timestamp);
-
-		// start engine and process its state update
-		IntroducerEngine engine = new IntroducerEngine();
-		processStateUpdate(txn,
-				engine.onLocalAction(localState, localAction));
-	}
-
-	public void incomingMessage(Transaction txn, BdfDictionary state,
-			BdfDictionary message) throws DbException, FormatException {
-
-		IntroducerEngine engine = new IntroducerEngine();
-		processStateUpdate(txn,
-				engine.onMessageReceived(state, message));
-	}
-
-	private void processStateUpdate(Transaction txn,
-			IntroducerEngine.StateUpdate<BdfDictionary, BdfDictionary>
-					result) throws DbException, FormatException {
-
-		// save new local state
-		MessageId storageId =
-				new MessageId(result.localState.getRaw(STORAGE_ID));
-		clientHelper.mergeMessageMetadata(txn, storageId, result.localState);
-
-		// send messages
-		for (BdfDictionary d : result.toSend) {
-			messageSender.sendMessage(txn, d);
-		}
-
-		// broadcast events
-		for (Event event : result.toBroadcast) {
-			txn.attach(event);
-		}
-	}
-
-	public void abort(Transaction txn, BdfDictionary state) {
-		IntroducerEngine engine = new IntroducerEngine();
-		BdfDictionary localAction = new BdfDictionary();
-		localAction.put(TYPE, TYPE_ABORT);
-		try {
-			processStateUpdate(txn,
-					engine.onLocalAction(state, localAction));
-		} catch (DbException | IOException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-	}
-
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerProtocolEngine.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..ed19fe651a5d1d0693426cd97fbaab89c031151e
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerProtocolEngine.java
@@ -0,0 +1,513 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.client.ContactGroupFactory;
+import org.briarproject.bramble.api.contact.ContactManager;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.briar.api.client.MessageTracker;
+import org.briarproject.briar.api.client.ProtocolStateException;
+import org.briarproject.briar.api.introduction.event.IntroductionAbortedEvent;
+import org.briarproject.briar.introduction.IntroducerSession.Introducee;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_ACTIVATES;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_ACTIVATE_A;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_ACTIVATE_B;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_AUTHS;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_AUTH_A;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_AUTH_B;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_RESPONSES;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_RESPONSE_A;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_RESPONSE_B;
+import static org.briarproject.briar.introduction.IntroducerState.A_DECLINED;
+import static org.briarproject.briar.introduction.IntroducerState.B_DECLINED;
+import static org.briarproject.briar.introduction.IntroducerState.START;
+
+@Immutable
+@NotNullByDefault
+class IntroducerProtocolEngine
+		extends AbstractProtocolEngine<IntroducerSession> {
+
+	@Inject
+	IntroducerProtocolEngine(
+			DatabaseComponent db,
+			ClientHelper clientHelper,
+			ContactManager contactManager,
+			ContactGroupFactory contactGroupFactory,
+			MessageTracker messageTracker,
+			IdentityManager identityManager,
+			MessageParser messageParser,
+			MessageEncoder messageEncoder,
+			Clock clock) {
+		super(db, clientHelper, contactManager, contactGroupFactory,
+				messageTracker, identityManager, messageParser, messageEncoder,
+				clock);
+	}
+
+	@Override
+	public IntroducerSession onRequestAction(Transaction txn,
+			IntroducerSession s, @Nullable String message, long timestamp)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+				return onLocalRequest(txn, s, message, timestamp);
+			case AWAIT_RESPONSES:
+			case AWAIT_RESPONSE_A:
+			case AWAIT_RESPONSE_B:
+			case A_DECLINED:
+			case B_DECLINED:
+			case AWAIT_AUTHS:
+			case AWAIT_AUTH_A:
+			case AWAIT_AUTH_B:
+			case AWAIT_ACTIVATES:
+			case AWAIT_ACTIVATE_A:
+			case AWAIT_ACTIVATE_B:
+				throw new ProtocolStateException(); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroducerSession onAcceptAction(Transaction txn,
+			IntroducerSession s, long timestamp) {
+		throw new UnsupportedOperationException(); // Invalid in this role
+	}
+
+	@Override
+	public IntroducerSession onDeclineAction(Transaction txn,
+			IntroducerSession s, long timestamp) {
+		throw new UnsupportedOperationException(); // Invalid in this role
+	}
+
+	IntroducerSession onAbortAction(Transaction txn, IntroducerSession s)
+			throws DbException {
+		return abort(txn, s);
+	}
+
+	@Override
+	public IntroducerSession onRequestMessage(Transaction txn,
+			IntroducerSession s, RequestMessage m) throws DbException {
+		return abort(txn, s); // Invalid in this role
+	}
+
+	@Override
+	public IntroducerSession onAcceptMessage(Transaction txn,
+			IntroducerSession s, AcceptMessage m) throws DbException {
+		switch (s.getState()) {
+			case AWAIT_RESPONSES:
+			case AWAIT_RESPONSE_A:
+			case AWAIT_RESPONSE_B:
+				return onRemoteAccept(txn, s, m);
+			case A_DECLINED:
+			case B_DECLINED:
+				return onRemoteResponseWhenDeclined(txn, s, m);
+			case START:
+			case AWAIT_AUTHS:
+			case AWAIT_AUTH_A:
+			case AWAIT_AUTH_B:
+			case AWAIT_ACTIVATES:
+			case AWAIT_ACTIVATE_A:
+			case AWAIT_ACTIVATE_B:
+				return abort(txn, s); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroducerSession onDeclineMessage(Transaction txn,
+			IntroducerSession s, DeclineMessage m) throws DbException {
+		switch (s.getState()) {
+			case AWAIT_RESPONSES:
+			case AWAIT_RESPONSE_A:
+			case AWAIT_RESPONSE_B:
+				return onRemoteDecline(txn, s, m);
+			case A_DECLINED:
+			case B_DECLINED:
+				return onRemoteResponseWhenDeclined(txn, s, m);
+			case START:
+			case AWAIT_AUTHS:
+			case AWAIT_AUTH_A:
+			case AWAIT_AUTH_B:
+			case AWAIT_ACTIVATES:
+			case AWAIT_ACTIVATE_A:
+			case AWAIT_ACTIVATE_B:
+				return abort(txn, s); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroducerSession onAuthMessage(Transaction txn, IntroducerSession s,
+			AuthMessage m) throws DbException {
+		switch (s.getState()) {
+			case AWAIT_AUTHS:
+			case AWAIT_AUTH_A:
+			case AWAIT_AUTH_B:
+				return onRemoteAuth(txn, s, m);
+			case START:
+			case AWAIT_RESPONSES:
+			case AWAIT_RESPONSE_A:
+			case AWAIT_RESPONSE_B:
+			case A_DECLINED:
+			case B_DECLINED:
+			case AWAIT_ACTIVATES:
+			case AWAIT_ACTIVATE_A:
+			case AWAIT_ACTIVATE_B:
+				return abort(txn, s); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroducerSession onActivateMessage(Transaction txn,
+			IntroducerSession s, ActivateMessage m) throws DbException {
+		switch (s.getState()) {
+			case AWAIT_ACTIVATES:
+			case AWAIT_ACTIVATE_A:
+			case AWAIT_ACTIVATE_B:
+				return onRemoteActivate(txn, s, m);
+			case START:
+			case AWAIT_RESPONSES:
+			case AWAIT_RESPONSE_A:
+			case AWAIT_RESPONSE_B:
+			case A_DECLINED:
+			case B_DECLINED:
+			case AWAIT_AUTHS:
+			case AWAIT_AUTH_A:
+			case AWAIT_AUTH_B:
+				return abort(txn, s); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public IntroducerSession onAbortMessage(Transaction txn,
+			IntroducerSession s, AbortMessage m) throws DbException {
+		return onRemoteAbort(txn, s, m);
+	}
+
+	private IntroducerSession onLocalRequest(Transaction txn,
+			IntroducerSession s,
+			@Nullable String message, long timestamp) throws DbException {
+		// Send REQUEST messages
+		long maxIntroduceeTimestamp =
+				Math.max(getLocalTimestamp(s, s.getIntroduceeA()),
+						getLocalTimestamp(s, s.getIntroduceeB()));
+		long localTimestamp = Math.max(timestamp, maxIntroduceeTimestamp);
+		Message sentA = sendRequestMessage(txn, s.getIntroduceeA(),
+				localTimestamp, s.getIntroduceeB().author, message
+		);
+		Message sentB = sendRequestMessage(txn, s.getIntroduceeB(),
+				localTimestamp, s.getIntroduceeA().author, message
+		);
+		// Track the messages
+		messageTracker.trackOutgoingMessage(txn, sentA);
+		messageTracker.trackOutgoingMessage(txn, sentB);
+		// Move to the AWAIT_RESPONSES state
+		Introducee introduceeA = new Introducee(s.getIntroduceeA(), sentA);
+		Introducee introduceeB = new Introducee(s.getIntroduceeB(), sentB);
+		return new IntroducerSession(s.getSessionId(), AWAIT_RESPONSES,
+				localTimestamp, introduceeA, introduceeB);
+	}
+
+	private IntroducerSession onRemoteAccept(Transaction txn,
+			IntroducerSession s, AcceptMessage m) throws DbException {
+		// The timestamp must be higher than the last request message
+		if (m.getTimestamp() <= s.getRequestTimestamp())
+			return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getGroupId(), m.getPreviousMessageId()))
+			return abort(txn, s);
+		// The message must be expected in the current state
+		boolean senderIsAlice = senderIsAlice(s, m);
+		if (s.getState() != AWAIT_RESPONSES) {
+			if (senderIsAlice && s.getState() != AWAIT_RESPONSE_A)
+				return abort(txn, s);
+			else if (!senderIsAlice && s.getState() != AWAIT_RESPONSE_B)
+				return abort(txn, s);
+		}
+
+		// Mark the response visible in the UI
+		markMessageVisibleInUi(txn, m.getMessageId());
+		// Track the incoming message
+		messageTracker
+				.trackMessage(txn, m.getGroupId(), m.getTimestamp(), false);
+
+		// Forward ACCEPT message
+		Introducee i = getOtherIntroducee(s, m.getGroupId());
+		long timestamp = getLocalTimestamp(s, i);
+		Message sent =
+				sendAcceptMessage(txn, i, timestamp, m.getEphemeralPublicKey(),
+						m.getAcceptTimestamp(), m.getTransportProperties(),
+						false);
+
+		// Create the next state
+		IntroducerState state = AWAIT_AUTHS;
+		Introducee introduceeA, introduceeB;
+		if (senderIsAlice) {
+			if (s.getState() == AWAIT_RESPONSES) state = AWAIT_RESPONSE_B;
+			introduceeA = new Introducee(s.getIntroduceeA(), m.getMessageId());
+			introduceeB = new Introducee(s.getIntroduceeB(), sent);
+		} else {
+			if (s.getState() == AWAIT_RESPONSES) state = AWAIT_RESPONSE_A;
+			introduceeA = new Introducee(s.getIntroduceeA(), sent);
+			introduceeB = new Introducee(s.getIntroduceeB(), m.getMessageId());
+		}
+
+		// Broadcast IntroductionResponseReceivedEvent
+		Author sender = senderIsAlice ? introduceeA.author : introduceeB.author;
+		broadcastIntroductionResponseReceivedEvent(txn, s, sender.getId(), m);
+
+		// Move to the next state
+		return new IntroducerSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private boolean senderIsAlice(IntroducerSession s,
+			AbstractIntroductionMessage m) {
+		return m.getGroupId().equals(s.getIntroduceeA().groupId);
+	}
+
+	private IntroducerSession onRemoteDecline(Transaction txn,
+			IntroducerSession s, DeclineMessage m) throws DbException {
+		// The timestamp must be higher than the last request message
+		if (m.getTimestamp() <= s.getRequestTimestamp())
+			return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getGroupId(), m.getPreviousMessageId()))
+			return abort(txn, s);
+		// The message must be expected in the current state
+		boolean senderIsAlice = senderIsAlice(s, m);
+		if (s.getState() != AWAIT_RESPONSES) {
+			if (senderIsAlice && s.getState() != AWAIT_RESPONSE_A)
+				return abort(txn, s);
+			else if (!senderIsAlice && s.getState() != AWAIT_RESPONSE_B)
+				return abort(txn, s);
+		}
+
+		// Mark the response visible in the UI
+		markMessageVisibleInUi(txn, m.getMessageId());
+		// Track the incoming message
+		messageTracker
+				.trackMessage(txn, m.getGroupId(), m.getTimestamp(), false);
+
+		// Forward DECLINE message
+		Introducee i = getOtherIntroducee(s, m.getGroupId());
+		long timestamp = getLocalTimestamp(s, i);
+		Message sent = sendDeclineMessage(txn, i, timestamp, false);
+
+		// Create the next state
+		IntroducerState state = START;
+		Introducee introduceeA, introduceeB;
+		if (senderIsAlice) {
+			if (s.getState() == AWAIT_RESPONSES) state = A_DECLINED;
+			introduceeA = new Introducee(s.getIntroduceeA(), m.getMessageId());
+			introduceeB = new Introducee(s.getIntroduceeB(), sent);
+		} else {
+			if (s.getState() == AWAIT_RESPONSES) state = B_DECLINED;
+			introduceeA = new Introducee(s.getIntroduceeA(), sent);
+			introduceeB = new Introducee(s.getIntroduceeB(), m.getMessageId());
+		}
+
+		// Broadcast IntroductionResponseReceivedEvent
+		Author sender = senderIsAlice ? introduceeA.author : introduceeB.author;
+		broadcastIntroductionResponseReceivedEvent(txn, s, sender.getId(), m);
+
+		return new IntroducerSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private IntroducerSession onRemoteResponseWhenDeclined(Transaction txn,
+			IntroducerSession s, AbstractIntroductionMessage m)
+			throws DbException {
+		// The timestamp must be higher than the last request message
+		if (m.getTimestamp() <= s.getRequestTimestamp())
+			return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getGroupId(), m.getPreviousMessageId()))
+			return abort(txn, s);
+		// The message must be expected in the current state
+		boolean senderIsAlice = senderIsAlice(s, m);
+		if (senderIsAlice && s.getState() != B_DECLINED)
+			return abort(txn, s);
+		else if (!senderIsAlice && s.getState() != A_DECLINED)
+			return abort(txn, s);
+
+		// Mark the response visible in the UI
+		markMessageVisibleInUi(txn, m.getMessageId());
+		// Track the incoming message
+		messageTracker
+				.trackMessage(txn, m.getGroupId(), m.getTimestamp(), false);
+
+		Introducee introduceeA, introduceeB;
+		if (senderIsAlice) {
+			introduceeA = new Introducee(s.getIntroduceeA(), m.getMessageId());
+			introduceeB = s.getIntroduceeB();
+		} else {
+			introduceeA = s.getIntroduceeA();
+			introduceeB = new Introducee(s.getIntroduceeB(), m.getMessageId());
+		}
+
+		// Broadcast IntroductionResponseReceivedEvent
+		Author sender = senderIsAlice ? introduceeA.author : introduceeB.author;
+		broadcastIntroductionResponseReceivedEvent(txn, s, sender.getId(), m);
+
+		return new IntroducerSession(s.getSessionId(), START,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private IntroducerSession onRemoteAuth(Transaction txn,
+			IntroducerSession s, AuthMessage m) throws DbException {
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getGroupId(), m.getPreviousMessageId()))
+			return abort(txn, s);
+		// The message must be expected in the current state
+		boolean senderIsAlice = senderIsAlice(s, m);
+		if (s.getState() != AWAIT_AUTHS) {
+			if (senderIsAlice && s.getState() != AWAIT_AUTH_A)
+				return abort(txn, s);
+			else if (!senderIsAlice && s.getState() != AWAIT_AUTH_B)
+				return abort(txn, s);
+		}
+
+		// Forward AUTH message
+		Introducee i = getOtherIntroducee(s, m.getGroupId());
+		long timestamp = getLocalTimestamp(s, i);
+		Message sent = sendAuthMessage(txn, i, timestamp, m.getMac(),
+				m.getSignature());
+
+		// Move to the next state
+		IntroducerState state = AWAIT_ACTIVATES;
+		Introducee introduceeA, introduceeB;
+		if (senderIsAlice) {
+			if (s.getState() == AWAIT_AUTHS) state = AWAIT_AUTH_B;
+			introduceeA = new Introducee(s.getIntroduceeA(), m.getMessageId());
+			introduceeB = new Introducee(s.getIntroduceeB(), sent);
+		} else {
+			if (s.getState() == AWAIT_AUTHS) state = AWAIT_AUTH_A;
+			introduceeA = new Introducee(s.getIntroduceeA(), sent);
+			introduceeB = new Introducee(s.getIntroduceeB(), m.getMessageId());
+		}
+		return new IntroducerSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private IntroducerSession onRemoteActivate(Transaction txn,
+			IntroducerSession s, ActivateMessage m) throws DbException {
+		// The dependency, if any, must be the last remote message
+		if (isInvalidDependency(s, m.getGroupId(), m.getPreviousMessageId()))
+			return abort(txn, s);
+		// The message must be expected in the current state
+		boolean senderIsAlice = senderIsAlice(s, m);
+		if (s.getState() != AWAIT_ACTIVATES) {
+			if (senderIsAlice && s.getState() != AWAIT_ACTIVATE_A)
+				return abort(txn, s);
+			else if (!senderIsAlice && s.getState() != AWAIT_ACTIVATE_B)
+				return abort(txn, s);
+		}
+
+		// Forward ACTIVATE message
+		Introducee i = getOtherIntroducee(s, m.getGroupId());
+		long timestamp = getLocalTimestamp(s, i);
+		Message sent = sendActivateMessage(txn, i, timestamp, m.getMac());
+
+		// Move to the next state
+		IntroducerState state = START;
+		Introducee introduceeA, introduceeB;
+		if (senderIsAlice) {
+			if (s.getState() == AWAIT_ACTIVATES) state = AWAIT_ACTIVATE_B;
+			introduceeA = new Introducee(s.getIntroduceeA(), m.getMessageId());
+			introduceeB = new Introducee(s.getIntroduceeB(), sent);
+		} else {
+			if (s.getState() == AWAIT_ACTIVATES) state = AWAIT_ACTIVATE_A;
+			introduceeA = new Introducee(s.getIntroduceeA(), sent);
+			introduceeB = new Introducee(s.getIntroduceeB(), m.getMessageId());
+		}
+		return new IntroducerSession(s.getSessionId(), state,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private IntroducerSession onRemoteAbort(Transaction txn,
+			IntroducerSession s, AbortMessage m) throws DbException {
+		// Forward ABORT message
+		Introducee i = getOtherIntroducee(s, m.getGroupId());
+		long timestamp = getLocalTimestamp(s, i);
+		Message sent = sendAbortMessage(txn, i, timestamp);
+
+		// Broadcast abort event for testing
+		txn.attach(new IntroductionAbortedEvent(s.getSessionId()));
+
+		// Reset the session back to initial state
+		Introducee introduceeA, introduceeB;
+		if (i.equals(s.getIntroduceeA())) {
+			introduceeA = new Introducee(s.getIntroduceeA(), sent);
+			introduceeB = new Introducee(s.getIntroduceeB(), m.getMessageId());
+		} else if (i.equals(s.getIntroduceeB())) {
+			introduceeA = new Introducee(s.getIntroduceeA(), m.getMessageId());
+			introduceeB = new Introducee(s.getIntroduceeB(), sent);
+		} else throw new AssertionError();
+		return new IntroducerSession(s.getSessionId(), START,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private IntroducerSession abort(Transaction txn,
+			IntroducerSession s) throws DbException {
+		// Broadcast abort event for testing
+		txn.attach(new IntroductionAbortedEvent(s.getSessionId()));
+
+		// Send an ABORT message to both introducees
+		long timestampA = getLocalTimestamp(s, s.getIntroduceeA());
+		Message sentA = sendAbortMessage(txn, s.getIntroduceeA(), timestampA);
+		long timestampB = getLocalTimestamp(s, s.getIntroduceeB());
+		Message sentB = sendAbortMessage(txn, s.getIntroduceeB(), timestampB);
+		// Reset the session back to initial state
+		Introducee introduceeA = new Introducee(s.getIntroduceeA(), sentA);
+		Introducee introduceeB = new Introducee(s.getIntroduceeB(), sentB);
+		return new IntroducerSession(s.getSessionId(), START,
+				s.getRequestTimestamp(), introduceeA, introduceeB);
+	}
+
+	private Introducee getIntroducee(IntroducerSession s, GroupId g) {
+		if (s.getIntroduceeA().groupId.equals(g)) return s.getIntroduceeA();
+		else if (s.getIntroduceeB().groupId.equals(g))
+			return s.getIntroduceeB();
+		else throw new AssertionError();
+	}
+
+	private Introducee getOtherIntroducee(IntroducerSession s, GroupId g) {
+		if (s.getIntroduceeA().groupId.equals(g)) return s.getIntroduceeB();
+		else if (s.getIntroduceeB().groupId.equals(g))
+			return s.getIntroduceeA();
+		else throw new AssertionError();
+	}
+
+	private boolean isInvalidDependency(IntroducerSession session,
+			GroupId contactGroupId, @Nullable MessageId dependency) {
+		MessageId expected =
+				getIntroducee(session, contactGroupId).lastRemoteMessageId;
+		return isInvalidDependency(expected, dependency);
+	}
+
+	private long getLocalTimestamp(IntroducerSession s, PeerSession p) {
+		return getLocalTimestamp(p.getLocalTimestamp(),
+				s.getRequestTimestamp());
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerSession.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerSession.java
new file mode 100644
index 0000000000000000000000000000000000000000..c26eb26d981922f6d0a76e2ff6a86b3148683b99
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerSession.java
@@ -0,0 +1,115 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.Role;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.briar.api.introduction.Role.INTRODUCER;
+
+@Immutable
+@NotNullByDefault
+class IntroducerSession extends Session<IntroducerState> {
+
+	private final Introducee introduceeA, introduceeB;
+
+	IntroducerSession(SessionId sessionId, IntroducerState state,
+			long requestTimestamp, Introducee introduceeA,
+			Introducee introduceeB) {
+		super(sessionId, state, requestTimestamp);
+		this.introduceeA = introduceeA;
+		this.introduceeB = introduceeB;
+	}
+
+	IntroducerSession(SessionId sessionId, GroupId groupIdA, Author authorA,
+			GroupId groupIdB, Author authorB) {
+		this(sessionId, IntroducerState.START, -1,
+				new Introducee(sessionId, groupIdA, authorA),
+				new Introducee(sessionId, groupIdB, authorB));
+	}
+
+	@Override
+	Role getRole() {
+		return INTRODUCER;
+	}
+
+	Introducee getIntroduceeA() {
+		return introduceeA;
+	}
+
+	Introducee getIntroduceeB() {
+		return introduceeB;
+	}
+
+	@Immutable
+	@NotNullByDefault
+	static class Introducee implements PeerSession {
+		final SessionId sessionId;
+		final GroupId groupId;
+		final Author author;
+		final long localTimestamp;
+		@Nullable
+		final MessageId lastLocalMessageId, lastRemoteMessageId;
+
+		Introducee(SessionId sessionId, GroupId groupId, Author author,
+				long localTimestamp,
+				@Nullable MessageId lastLocalMessageId,
+				@Nullable MessageId lastRemoteMessageId) {
+			this.sessionId = sessionId;
+			this.groupId = groupId;
+			this.localTimestamp = localTimestamp;
+			this.author = author;
+			this.lastLocalMessageId = lastLocalMessageId;
+			this.lastRemoteMessageId = lastRemoteMessageId;
+		}
+
+		Introducee(Introducee i, Message sent) {
+			this(i.sessionId, i.groupId, i.author, sent.getTimestamp(),
+					sent.getId(), i.lastRemoteMessageId);
+		}
+
+		Introducee(Introducee i, MessageId remoteMessageId) {
+			this(i.sessionId, i.groupId, i.author, i.localTimestamp,
+					i.lastLocalMessageId, remoteMessageId);
+		}
+
+		private Introducee(SessionId sessionId, GroupId groupId,
+				Author author) {
+			this(sessionId, groupId, author, -1, null, null);
+		}
+
+		public SessionId getSessionId() {
+			return sessionId;
+		}
+
+		@Override
+		public GroupId getContactGroupId() {
+			return groupId;
+		}
+
+		@Override
+		public long getLocalTimestamp() {
+			return localTimestamp;
+		}
+
+		@Nullable
+		@Override
+		public MessageId getLastLocalMessageId() {
+			return lastLocalMessageId;
+		}
+
+		@Nullable
+		@Override
+		public MessageId getLastRemoteMessageId() {
+			return lastRemoteMessageId;
+		}
+
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerState.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..6514eca16feb6cfd511f8558d2c26cf8e7096742
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroducerState.java
@@ -0,0 +1,37 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+enum IntroducerState implements State {
+
+	START(0),
+	AWAIT_RESPONSES(1),
+	AWAIT_RESPONSE_A(2), AWAIT_RESPONSE_B(3),
+	A_DECLINED(4), B_DECLINED(5),
+	AWAIT_AUTHS(6),
+	AWAIT_AUTH_A(7), AWAIT_AUTH_B(8),
+	AWAIT_ACTIVATES(9),
+	AWAIT_ACTIVATE_A(10), AWAIT_ACTIVATE_B(11);
+
+	private final int value;
+
+	IntroducerState(int value) {
+		this.value = value;
+	}
+
+	@Override
+	public int getValue() {
+		return value;
+	}
+
+	static IntroducerState fromValue(int value) throws FormatException {
+		for (IntroducerState s : values()) if (s.value == value) return s;
+		throw new FormatException();
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionConstants.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..522759a55bcafa40e32b2cbe0efa691064d19893
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionConstants.java
@@ -0,0 +1,48 @@
+package org.briarproject.briar.introduction;
+
+interface IntroductionConstants {
+
+	// Group metadata keys
+	String GROUP_KEY_CONTACT_ID = "contactId";
+
+	// Message metadata keys
+	String MSG_KEY_MESSAGE_TYPE = "messageType";
+	String MSG_KEY_SESSION_ID = "sessionId";
+	String MSG_KEY_TIMESTAMP = "timestamp";
+	String MSG_KEY_LOCAL = "local";
+	String MSG_KEY_VISIBLE_IN_UI = "visibleInUi";
+	String MSG_KEY_AVAILABLE_TO_ANSWER = "availableToAnswer";
+
+	// Session Keys
+	String SESSION_KEY_SESSION_ID = "sessionId";
+	String SESSION_KEY_ROLE = "role";
+	String SESSION_KEY_STATE = "state";
+	String SESSION_KEY_REQUEST_TIMESTAMP = "requestTimestamp";
+	String SESSION_KEY_LOCAL_TIMESTAMP = "localTimestamp";
+	String SESSION_KEY_LAST_LOCAL_MESSAGE_ID = "lastLocalMessageId";
+	String SESSION_KEY_LAST_REMOTE_MESSAGE_ID = "lastRemoteMessageId";
+
+	// Session Keys Introducer
+	String SESSION_KEY_INTRODUCEE_A = "introduceeA";
+	String SESSION_KEY_INTRODUCEE_B = "introduceeB";
+	String SESSION_KEY_GROUP_ID = "groupId";
+	String SESSION_KEY_AUTHOR = "author";
+
+	// Session Keys Introducee
+	String SESSION_KEY_INTRODUCER = "introducer";
+	String SESSION_KEY_LOCAL = "local";
+	String SESSION_KEY_REMOTE = "remote";
+
+	String SESSION_KEY_MASTER_KEY = "masterKey";
+	String SESSION_KEY_TRANSPORT_KEYS = "transportKeys";
+
+	String SESSION_KEY_ALICE = "alice";
+	String SESSION_KEY_EPHEMERAL_PUBLIC_KEY = "ephemeralPublicKey";
+	String SESSION_KEY_EPHEMERAL_PRIVATE_KEY = "ephemeralPrivateKey";
+	String SESSION_KEY_TRANSPORT_PROPERTIES = "transportProperties";
+	String SESSION_KEY_ACCEPT_TIMESTAMP = "acceptTimestamp";
+	String SESSION_KEY_MAC_KEY = "macKey";
+
+	String SESSION_KEY_REMOTE_AUTHOR = "remoteAuthor";
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionCrypto.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionCrypto.java
new file mode 100644
index 0000000000000000000000000000000000000000..37f7aa10f63ffb9e25514d3d557139c408dd3433
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionCrypto.java
@@ -0,0 +1,100 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.crypto.KeyPair;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.briar.api.client.SessionId;
+
+import java.security.GeneralSecurityException;
+
+interface IntroductionCrypto {
+
+	/**
+	 * Returns the {@link SessionId} based on the introducer
+	 * and the two introducees.
+	 */
+	SessionId getSessionId(Author introducer, Author local, Author remote);
+
+	/**
+	 * Returns true if the local author is alice
+	 *
+	 * Alice is the Author whose unique ID has the lower ID,
+	 * comparing the IDs as byte strings.
+	 */
+	boolean isAlice(AuthorId local, AuthorId remote);
+
+	/**
+	 * Generates an agreement key pair.
+	 */
+	KeyPair generateKeyPair();
+
+	/**
+	 * Derives a session master key for Alice or Bob.
+	 *
+	 * @return The secret master key
+	 */
+	SecretKey deriveMasterKey(IntroduceeSession s)
+			throws GeneralSecurityException;
+
+	/**
+	 * Derives a MAC key from the session's master key for Alice or Bob.
+	 *
+	 * @param masterKey The key returned by {@link #deriveMasterKey(IntroduceeSession)}
+	 * @param alice true for Alice's MAC key, false for Bob's
+	 * @return The MAC key
+	 */
+	SecretKey deriveMacKey(SecretKey masterKey, boolean alice);
+
+	/**
+	 * Generates a MAC that covers both introducee's ephemeral public keys,
+	 * transport properties, Author IDs and timestamps of the accept message.
+	 */
+	byte[] authMac(SecretKey macKey, IntroduceeSession s,
+			AuthorId localAuthorId);
+
+	/**
+	 * Verifies a received MAC
+	 *
+	 * @param mac The MAC to verify
+	 * as returned by {@link #deriveMasterKey(IntroduceeSession)}
+	 * @throws GeneralSecurityException if the verification fails
+	 */
+	void verifyAuthMac(byte[] mac, IntroduceeSession s, AuthorId localAuthorId)
+			throws GeneralSecurityException;
+
+	/**
+	 * Signs a nonce derived from the macKey
+	 * with the local introducee's identity private key.
+	 *
+	 * @param macKey The corresponding MAC key for the signer's role
+	 * @param privateKey The identity private key
+	 * (from {@link LocalAuthor#getPrivateKey()})
+	 * @return The signature as a byte array
+	 */
+	byte[] sign(SecretKey macKey, byte[] privateKey)
+			throws GeneralSecurityException;
+
+	/**
+	 * Verifies the signature on a nonce derived from the MAC key.
+	 *
+	 * @throws GeneralSecurityException if the signature is invalid
+	 */
+	void verifySignature(byte[] signature, IntroduceeSession s)
+			throws GeneralSecurityException;
+
+	/**
+	 * Generates a MAC using the local MAC key.
+	 */
+	byte[] activateMac(IntroduceeSession s);
+
+	/**
+	 * Verifies a MAC from an ACTIVATE message.
+	 *
+	 * @throws GeneralSecurityException if the verification fails
+	 */
+	void verifyActivateMac(byte[] mac, IntroduceeSession s)
+			throws GeneralSecurityException;
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionCryptoImpl.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionCryptoImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..f9d53323b08aeb90b6f81ed46018051ce6418c6e
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionCryptoImpl.java
@@ -0,0 +1,239 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.crypto.CryptoComponent;
+import org.briarproject.bramble.api.crypto.KeyPair;
+import org.briarproject.bramble.api.crypto.KeyParser;
+import org.briarproject.bramble.api.crypto.PrivateKey;
+import org.briarproject.bramble.api.crypto.PublicKey;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.introduction.IntroduceeSession.Common;
+import org.briarproject.briar.introduction.IntroduceeSession.Remote;
+
+import java.security.GeneralSecurityException;
+
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_ACTIVATE_MAC;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_ALICE_MAC_KEY;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_AUTH_MAC;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_AUTH_NONCE;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_AUTH_SIGN;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_BOB_MAC_KEY;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_MASTER_KEY;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_SESSION_ID;
+import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_VERSION;
+import static org.briarproject.briar.introduction.IntroduceeSession.Local;
+
+@Immutable
+@NotNullByDefault
+class IntroductionCryptoImpl implements IntroductionCrypto {
+
+	private final CryptoComponent crypto;
+	private final ClientHelper clientHelper;
+
+	@Inject
+	IntroductionCryptoImpl(
+			CryptoComponent crypto,
+			ClientHelper clientHelper) {
+		this.crypto = crypto;
+		this.clientHelper = clientHelper;
+	}
+
+	@Override
+	public SessionId getSessionId(Author introducer, Author local,
+			Author remote) {
+		boolean isAlice = isAlice(local.getId(), remote.getId());
+		byte[] hash = crypto.hash(
+				LABEL_SESSION_ID,
+				introducer.getId().getBytes(),
+				isAlice ? local.getId().getBytes() : remote.getId().getBytes(),
+				isAlice ? remote.getId().getBytes() : local.getId().getBytes()
+		);
+		return new SessionId(hash);
+	}
+
+	@Override
+	public KeyPair generateKeyPair() {
+		return crypto.generateAgreementKeyPair();
+	}
+
+	@Override
+	public boolean isAlice(AuthorId local, AuthorId remote) {
+		return local.compareTo(remote) < 0;
+	}
+
+	@Override
+	@SuppressWarnings("ConstantConditions")
+	public SecretKey deriveMasterKey(IntroduceeSession s)
+			throws GeneralSecurityException {
+		return deriveMasterKey(
+				s.getLocal().ephemeralPublicKey,
+				s.getLocal().ephemeralPrivateKey,
+				s.getRemote().ephemeralPublicKey,
+				s.getLocal().alice
+		);
+	}
+
+	SecretKey deriveMasterKey(byte[] publicKey, byte[] privateKey,
+			byte[] remotePublicKey, boolean alice)
+			throws GeneralSecurityException {
+		KeyParser kp = crypto.getAgreementKeyParser();
+		PublicKey remoteEphemeralPublicKey = kp.parsePublicKey(remotePublicKey);
+		PublicKey ephemeralPublicKey = kp.parsePublicKey(publicKey);
+		PrivateKey ephemeralPrivateKey = kp.parsePrivateKey(privateKey);
+		KeyPair keyPair = new KeyPair(ephemeralPublicKey, ephemeralPrivateKey);
+		return crypto.deriveSharedSecret(
+				LABEL_MASTER_KEY,
+				remoteEphemeralPublicKey,
+				keyPair,
+				new byte[] {CLIENT_VERSION},
+				alice ? publicKey : remotePublicKey,
+				alice ? remotePublicKey : publicKey
+		);
+	}
+
+	@Override
+	public SecretKey deriveMacKey(SecretKey masterKey, boolean alice) {
+		return crypto.deriveKey(
+				alice ? LABEL_ALICE_MAC_KEY : LABEL_BOB_MAC_KEY,
+				masterKey
+		);
+	}
+
+	@Override
+	@SuppressWarnings("ConstantConditions")
+	public byte[] authMac(SecretKey macKey, IntroduceeSession s,
+			AuthorId localAuthorId) {
+		// the macKey is not yet available in the session at this point
+		return authMac(macKey, s.getIntroducer().getId(), localAuthorId,
+				s.getLocal(), s.getRemote());
+	}
+
+	byte[] authMac(SecretKey macKey, AuthorId introducerId,
+			AuthorId localAuthorId, Local local, Remote remote) {
+		byte[] inputs = getAuthMacInputs(introducerId, localAuthorId, local,
+				remote.author.getId(), remote);
+		return crypto.mac(
+				LABEL_AUTH_MAC,
+				macKey,
+				inputs
+		);
+	}
+
+	@Override
+	@SuppressWarnings("ConstantConditions")
+	public void verifyAuthMac(byte[] mac, IntroduceeSession s,
+			AuthorId localAuthorId) throws GeneralSecurityException {
+		verifyAuthMac(mac, new SecretKey(s.getRemote().macKey),
+				s.getIntroducer().getId(), localAuthorId, s.getLocal(),
+				s.getRemote().author.getId(), s.getRemote());
+	}
+
+	void verifyAuthMac(byte[] mac, SecretKey macKey, AuthorId introducerId,
+			AuthorId localAuthorId, Common local, AuthorId remoteAuthorId,
+			Common remote) throws GeneralSecurityException {
+		// switch input for verification
+		byte[] inputs = getAuthMacInputs(introducerId, remoteAuthorId, remote,
+				localAuthorId, local);
+		if (!crypto.verifyMac(mac, LABEL_AUTH_MAC, macKey, inputs)) {
+			throw new GeneralSecurityException();
+		}
+	}
+
+	@SuppressWarnings("ConstantConditions")
+	private byte[] getAuthMacInputs(AuthorId introducerId,
+			AuthorId localAuthorId, Common local, AuthorId remoteAuthorId,
+			Common remote) {
+		BdfList localInfo = BdfList.of(
+				localAuthorId,
+				local.acceptTimestamp,
+				local.ephemeralPublicKey,
+				clientHelper.toDictionary(local.transportProperties)
+		);
+		BdfList remoteInfo = BdfList.of(
+				remoteAuthorId,
+				remote.acceptTimestamp,
+				remote.ephemeralPublicKey,
+				clientHelper.toDictionary(remote.transportProperties)
+		);
+		BdfList macList = BdfList.of(
+				introducerId,
+				localInfo,
+				remoteInfo
+		);
+		try {
+			return clientHelper.toByteArray(macList);
+		} catch (FormatException e) {
+			throw new AssertionError();
+		}
+	}
+
+	@Override
+	public byte[] sign(SecretKey macKey, byte[] privateKey)
+			throws GeneralSecurityException {
+		return crypto.sign(
+				LABEL_AUTH_SIGN,
+				getNonce(macKey),
+				privateKey
+		);
+	}
+
+	@Override
+	@SuppressWarnings("ConstantConditions")
+	public void verifySignature(byte[] signature, IntroduceeSession s)
+			throws GeneralSecurityException {
+		SecretKey macKey = new SecretKey(s.getRemote().macKey);
+		verifySignature(macKey, s.getRemote().author.getPublicKey(), signature);
+	}
+
+	void verifySignature(SecretKey macKey, byte[] publicKey,
+			byte[] signature) throws GeneralSecurityException {
+		byte[] nonce = getNonce(macKey);
+		if (!crypto.verifySignature(signature, LABEL_AUTH_SIGN, nonce,
+				publicKey)) {
+			throw new GeneralSecurityException();
+		}
+	}
+
+	private byte[] getNonce(SecretKey macKey) {
+		return crypto.mac(LABEL_AUTH_NONCE, macKey);
+	}
+
+	@Override
+	public byte[] activateMac(IntroduceeSession s) {
+		if (s.getLocal().macKey == null)
+			throw new AssertionError("Local MAC key is null");
+		return activateMac(new SecretKey(s.getLocal().macKey));
+	}
+
+	byte[] activateMac(SecretKey macKey) {
+		return crypto.mac(
+				LABEL_ACTIVATE_MAC,
+				macKey
+		);
+	}
+
+	@Override
+	public void verifyActivateMac(byte[] mac, IntroduceeSession s)
+			throws GeneralSecurityException {
+		if (s.getRemote().macKey == null)
+			throw new AssertionError("Remote MAC key is null");
+		verifyActivateMac(mac, new SecretKey(s.getRemote().macKey));
+	}
+
+	void verifyActivateMac(byte[] mac, SecretKey macKey)
+			throws GeneralSecurityException {
+		if (!crypto.verifyMac(mac, LABEL_ACTIVATE_MAC, macKey)) {
+			throw new GeneralSecurityException();
+		}
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionGroupFactory.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionGroupFactory.java
deleted file mode 100644
index 050d2b9f4430bcf38f950ea6c3dd941d0ac44cd2..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionGroupFactory.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.client.ContactGroupFactory;
-import org.briarproject.bramble.api.contact.Contact;
-import org.briarproject.bramble.api.sync.Group;
-
-import javax.inject.Inject;
-
-import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
-import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_VERSION;
-
-class IntroductionGroupFactory {
-
-	private final ContactGroupFactory contactGroupFactory;
-	private final Group localGroup;
-
-	@Inject
-	IntroductionGroupFactory(ContactGroupFactory contactGroupFactory) {
-		this.contactGroupFactory = contactGroupFactory;
-		localGroup = contactGroupFactory.createLocalGroup(CLIENT_ID,
-				CLIENT_VERSION);
-	}
-
-	Group createIntroductionGroup(Contact c) {
-		return contactGroupFactory.createContactGroup(CLIENT_ID,
-				CLIENT_VERSION, c);
-	}
-
-	Group createLocalGroup() {
-		return localGroup;
-	}
-
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionManagerImpl.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionManagerImpl.java
index 9ccf06b6796fdf869500d8e7233042950c9a84ec..efb857a0f57699ae41d9bacd541656085fc80eb7 100644
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionManagerImpl.java
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionManagerImpl.java
@@ -2,19 +2,21 @@ package org.briarproject.briar.introduction;
 
 import org.briarproject.bramble.api.FormatException;
 import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.client.ContactGroupFactory;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
+import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.contact.ContactManager.ContactHook;
 import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfEntry;
 import org.briarproject.bramble.api.data.BdfList;
 import org.briarproject.bramble.api.data.MetadataParser;
 import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.NoSuchContactException;
-import org.briarproject.bramble.api.db.NoSuchMessageException;
+import org.briarproject.bramble.api.db.Metadata;
 import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.identity.LocalAuthor;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.sync.Client;
 import org.briarproject.bramble.api.sync.Group;
@@ -24,412 +26,394 @@ import org.briarproject.bramble.api.sync.MessageId;
 import org.briarproject.bramble.api.sync.MessageStatus;
 import org.briarproject.briar.api.client.MessageTracker;
 import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.api.introduction.IntroducerProtocolState;
 import org.briarproject.briar.api.introduction.IntroductionManager;
 import org.briarproject.briar.api.introduction.IntroductionMessage;
 import org.briarproject.briar.api.introduction.IntroductionRequest;
 import org.briarproject.briar.api.introduction.IntroductionResponse;
+import org.briarproject.briar.api.introduction.Role;
 import org.briarproject.briar.client.ConversationClientImpl;
+import org.briarproject.briar.introduction.IntroducerSession.Introducee;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
 import java.util.Map;
-import java.util.logging.Logger;
+import java.util.Map.Entry;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.Immutable;
 import javax.inject.Inject;
 
-import static java.util.logging.Level.WARNING;
 import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.FINISHED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ANSWERED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.EXISTS;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NOT_OUR_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_IS_US;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.RESPONSE_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.RESPONSE_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCEE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-import static org.briarproject.briar.client.MessageTrackerConstants.MSG_KEY_READ;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCEE;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCER;
+import static org.briarproject.briar.introduction.IntroducerState.START;
+import static org.briarproject.briar.introduction.IntroductionConstants.GROUP_KEY_CONTACT_ID;
+import static org.briarproject.briar.introduction.MessageType.ABORT;
+import static org.briarproject.briar.introduction.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction.MessageType.ACTIVATE;
+import static org.briarproject.briar.introduction.MessageType.AUTH;
+import static org.briarproject.briar.introduction.MessageType.DECLINE;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
 
 @Immutable
 @NotNullByDefault
 class IntroductionManagerImpl extends ConversationClientImpl
 		implements IntroductionManager, Client, ContactHook {
 
-	private static final Logger LOG =
-			Logger.getLogger(IntroductionManagerImpl.class.getName());
+	private final ContactGroupFactory contactGroupFactory;
+	private final ContactManager contactManager;
+	private final MessageParser messageParser;
+	private final SessionEncoder sessionEncoder;
+	private final SessionParser sessionParser;
+	private final IntroducerProtocolEngine introducerEngine;
+	private final IntroduceeProtocolEngine introduceeEngine;
+	private final IntroductionCrypto crypto;
+	private final IdentityManager identityManager;
 
-	private final IntroducerManager introducerManager;
-	private final IntroduceeManager introduceeManager;
-	private final IntroductionGroupFactory introductionGroupFactory;
+	private final Group localGroup;
 
 	@Inject
-	IntroductionManagerImpl(DatabaseComponent db, ClientHelper clientHelper,
-			MetadataParser metadataParser, MessageTracker messageTracker,
-			IntroducerManager introducerManager,
-			IntroduceeManager introduceeManager,
-			IntroductionGroupFactory introductionGroupFactory) {
-
+	IntroductionManagerImpl(
+			DatabaseComponent db,
+			ClientHelper clientHelper,
+			MetadataParser metadataParser,
+			MessageTracker messageTracker,
+			ContactGroupFactory contactGroupFactory,
+			ContactManager contactManager,
+			MessageParser messageParser,
+			SessionEncoder sessionEncoder,
+			SessionParser sessionParser,
+			IntroducerProtocolEngine introducerEngine,
+			IntroduceeProtocolEngine introduceeEngine,
+			IntroductionCrypto crypto,
+			IdentityManager identityManager) {
 		super(db, clientHelper, metadataParser, messageTracker);
-		this.introducerManager = introducerManager;
-		this.introduceeManager = introduceeManager;
-		this.introductionGroupFactory = introductionGroupFactory;
+		this.contactGroupFactory = contactGroupFactory;
+		this.contactManager = contactManager;
+		this.messageParser = messageParser;
+		this.sessionEncoder = sessionEncoder;
+		this.sessionParser = sessionParser;
+		this.introducerEngine = introducerEngine;
+		this.introduceeEngine = introduceeEngine;
+		this.crypto = crypto;
+		this.identityManager = identityManager;
+		this.localGroup =
+				contactGroupFactory.createLocalGroup(CLIENT_ID, CLIENT_VERSION);
 	}
 
 	@Override
 	public void createLocalState(Transaction txn) throws DbException {
-		Group localGroup = introductionGroupFactory.createLocalGroup();
+		// Create a local group to store protocol sessions
 		if (db.containsGroup(txn, localGroup.getId())) return;
 		db.addGroup(txn, localGroup);
-		// Ensure we've set things up for any pre-existing contacts
+		// Set up groups for communication with any pre-existing contacts
 		for (Contact c : db.getContacts(txn)) addingContact(txn, c);
 	}
 
 	@Override
+	// TODO adapt to use upcoming ClientVersioning client
 	public void addingContact(Transaction txn, Contact c) throws DbException {
+		// Create a group to share with the contact
+		Group g = getContactGroup(c);
+		// Store the group and share it with the contact
+		db.addGroup(txn, g);
+		db.setGroupVisibility(txn, c.getId(), g.getId(), SHARED);
+		// Attach the contact ID to the group
+		BdfDictionary meta = new BdfDictionary();
+		meta.put(GROUP_KEY_CONTACT_ID, c.getId().getInt());
 		try {
-			// Create an introduction group for sending introduction messages
-			Group g = getContactGroup(c);
-			// Return if we've already set things up for this contact
-			if (db.containsGroup(txn, g.getId())) return;
-			// Store the group and share it with the contact
-			db.addGroup(txn, g);
-			db.setGroupVisibility(txn, c.getId(), g.getId(), SHARED);
-			// Attach the contact ID to the group
-			BdfDictionary gm = new BdfDictionary();
-			gm.put(CONTACT, c.getId().getInt());
-			clientHelper.mergeGroupMetadata(txn, g.getId(), gm);
+			clientHelper.mergeGroupMetadata(txn, g.getId(), meta);
 		} catch (FormatException e) {
-			throw new RuntimeException(e);
+			throw new AssertionError(e);
 		}
 	}
 
 	@Override
 	public void removingContact(Transaction txn, Contact c) throws DbException {
-		GroupId gId = introductionGroupFactory.createLocalGroup().getId();
-
-		// search for session states where c introduced us
-		BdfDictionary query = BdfDictionary.of(
-				new BdfEntry(ROLE, ROLE_INTRODUCEE),
-				new BdfEntry(CONTACT_ID_1, c.getId().getInt())
-		);
-		try {
-			Map<MessageId, BdfDictionary> map = clientHelper
-					.getMessageMetadataAsDictionary(txn, gId, query);
-			for (Map.Entry<MessageId, BdfDictionary> entry : map.entrySet()) {
-				// delete states if introducee removes introducer
-				deleteMessage(txn, entry.getKey());
-			}
-		} catch (FormatException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-
-		// check for open sessions with c and abort those,
-		// so the other introducee knows
-		query = BdfDictionary.of(
-				new BdfEntry(ROLE, ROLE_INTRODUCER)
-		);
-		try {
-			Map<MessageId, BdfDictionary> map = clientHelper
-					.getMessageMetadataAsDictionary(txn, gId, query);
-			for (Map.Entry<MessageId, BdfDictionary> entry : map.entrySet()) {
-				BdfDictionary d = entry.getValue();
-				ContactId c1 = new ContactId(d.getLong(CONTACT_ID_1).intValue());
-				ContactId c2 = new ContactId(d.getLong(CONTACT_ID_2).intValue());
-
-				if (c1.equals(c.getId()) || c2.equals(c.getId())) {
-					IntroducerProtocolState state = IntroducerProtocolState
-							.fromValue(d.getLong(STATE).intValue());
-					// abort protocol if still ongoing
-					if (IntroducerProtocolState.isOngoing(state)) {
-						introducerManager.abort(txn, d);
-					}
-					// also delete state if both contacts have been deleted
-					if (c1.equals(c.getId())) {
-						try {
-							db.getContact(txn, c2);
-						} catch (NoSuchContactException e) {
-							deleteMessage(txn, entry.getKey());
-						}
-					} else if (c2.equals(c.getId())) {
-						try {
-							db.getContact(txn, c1);
-						} catch (NoSuchContactException e) {
-							deleteMessage(txn, entry.getKey());
-						}
-					}
-				}
-			}
-		} catch (FormatException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
+		removeSessionWithIntroducer(txn, c);
+		abortOrRemoveSessionWithIntroducee(txn, c);
 
-		// remove the group (all messages will be removed with it)
-		// this contact won't get our abort message, but the other will
+		// Remove the contact group (all messages will be removed with it)
 		db.removeGroup(txn, getContactGroup(c));
 	}
 
-	/**
-	 * This is called when a new message arrived and is being validated.
-	 * It is the central method where we determine which role we play
-	 * in the introduction protocol and which engine we need to start.
-	 */
 	@Override
-	protected boolean incomingMessage(Transaction txn, Message m, BdfList body,
-			BdfDictionary message) throws DbException, FormatException {
-
-		// Get message data and type
-		GroupId groupId = m.getGroupId();
-		long type = message.getLong(TYPE, -1L);
+	public Group getContactGroup(Contact c) {
+		return contactGroupFactory
+				.createContactGroup(CLIENT_ID, CLIENT_VERSION, c);
+	}
 
-		// we are an introducee, need to initialize new state
-		if (type == TYPE_REQUEST) {
-			boolean stateExists = true;
-			try {
-				getSessionState(txn, groupId, message.getRaw(SESSION_ID), false);
-			} catch (FormatException e) {
-				stateExists = false;
-			}
-			if (stateExists) throw new FormatException();
-			BdfDictionary state =
-					introduceeManager.initialize(txn, groupId, message);
-			try {
-				introduceeManager.incomingMessage(txn, state, message);
-				messageTracker.trackIncomingMessage(txn, m);
-			} catch (DbException e) {
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-				introduceeManager.abort(txn, state);
-			} catch (FormatException e) {
-				// FIXME necessary?
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-				introduceeManager.abort(txn, state);
-			}
+	@Override
+	protected boolean incomingMessage(Transaction txn, Message m, BdfList body,
+			BdfDictionary bdfMeta) throws DbException, FormatException {
+		// Parse the metadata
+		MessageMetadata meta = messageParser.parseMetadata(bdfMeta);
+		// Look up the session, if there is one
+		SessionId sessionId = meta.getSessionId();
+		IntroduceeSession newIntroduceeSession = null;
+		if (sessionId == null) {
+			if (meta.getMessageType() != REQUEST) throw new AssertionError();
+			newIntroduceeSession = createNewIntroduceeSession(txn, m, body);
+			sessionId = newIntroduceeSession.getSessionId();
 		}
-		// our role can be anything
-		else if (type == TYPE_RESPONSE || type == TYPE_ACK || type == TYPE_ABORT) {
-			BdfDictionary state =
-					getSessionState(txn, groupId, message.getRaw(SESSION_ID));
-
-			long role = state.getLong(ROLE, -1L);
-			try {
-				if (role == ROLE_INTRODUCER) {
-					introducerManager.incomingMessage(txn, state, message);
-					if (type == TYPE_RESPONSE)
-						messageTracker.trackIncomingMessage(txn, m);
-				} else if (role == ROLE_INTRODUCEE) {
-					introduceeManager.incomingMessage(txn, state, message);
-					if (type == TYPE_RESPONSE && !message.getBoolean(ACCEPT))
-						messageTracker.trackIncomingMessage(txn, m);
-				} else {
-					if (LOG.isLoggable(WARNING))
-						LOG.warning("Unknown role '" + role + "'");
-					throw new DbException();
-				}
-			} catch (DbException | FormatException e) {
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-				if (role == ROLE_INTRODUCER) introducerManager.abort(txn, state);
-				else introduceeManager.abort(txn, state);
-			}
+		StoredSession ss = getSession(txn, sessionId);
+		// Handle the message
+		Session session;
+		MessageId storageId;
+		if (ss == null) {
+			if (meta.getMessageType() != REQUEST) throw new FormatException();
+			if (newIntroduceeSession == null) throw new AssertionError();
+			storageId = createStorageId(txn);
+			session = handleMessage(txn, m, body, meta.getMessageType(),
+					newIntroduceeSession, introduceeEngine);
 		} else {
-			// the message has been validated, so this should not happen
-			if(LOG.isLoggable(WARNING)) {
-				LOG.warning("Unknown message type '" + type + "', deleting...");
-			}
+			storageId = ss.storageId;
+			Role role = sessionParser.getRole(ss.bdfSession);
+			if (role == INTRODUCER) {
+				session = handleMessage(txn, m, body, meta.getMessageType(),
+						sessionParser.parseIntroducerSession(ss.bdfSession),
+						introducerEngine);
+			} else if (role == INTRODUCEE) {
+				session = handleMessage(txn, m, body, meta.getMessageType(),
+						sessionParser.parseIntroduceeSession(m.getGroupId(),
+								ss.bdfSession), introduceeEngine);
+			} else throw new AssertionError();
 		}
+		// Store the updated session
+		storeSession(txn, storageId, session);
 		return false;
 	}
 
-	@Override
-	public Group getContactGroup(Contact contact) {
-		return introductionGroupFactory.createIntroductionGroup(contact);
+	private IntroduceeSession createNewIntroduceeSession(Transaction txn,
+			Message m, BdfList body) throws DbException, FormatException {
+		ContactId introducerId = getContactId(txn, m.getGroupId());
+		Author introducer = db.getContact(txn, introducerId).getAuthor();
+		Author local = identityManager.getLocalAuthor(txn);
+		Author remote = messageParser.parseRequestMessage(m, body).getAuthor();
+		if (local.equals(remote)) throw new FormatException();
+		SessionId sessionId = crypto.getSessionId(introducer, local, remote);
+		boolean alice = crypto.isAlice(local.getId(), remote.getId());
+		return IntroduceeSession
+				.getInitial(m.getGroupId(), sessionId, introducer, alice,
+						remote);
 	}
 
-	@Override
-	public void makeIntroduction(Contact c1, Contact c2, @Nullable String msg,
-			long timestamp) throws DbException, FormatException {
+	private <S extends Session> S handleMessage(Transaction txn, Message m,
+			BdfList body, MessageType type, S session, ProtocolEngine<S> engine)
+			throws DbException, FormatException {
+		if (type == REQUEST) {
+			RequestMessage request = messageParser.parseRequestMessage(m, body);
+			return engine.onRequestMessage(txn, session, request);
+		} else if (type == ACCEPT) {
+			AcceptMessage accept = messageParser.parseAcceptMessage(m, body);
+			return engine.onAcceptMessage(txn, session, accept);
+		} else if (type == DECLINE) {
+			DeclineMessage decline = messageParser.parseDeclineMessage(m, body);
+			return engine.onDeclineMessage(txn, session, decline);
+		} else if (type == AUTH) {
+			AuthMessage auth = messageParser.parseAuthMessage(m, body);
+			return engine.onAuthMessage(txn, session, auth);
+		} else if (type == ACTIVATE) {
+			ActivateMessage activate =
+					messageParser.parseActivateMessage(m, body);
+			return engine.onActivateMessage(txn, session, activate);
+		} else if (type == ABORT) {
+			AbortMessage abort = messageParser.parseAbortMessage(m, body);
+			return engine.onAbortMessage(txn, session, abort);
+		} else {
+			throw new AssertionError();
+		}
+	}
 
-		Transaction txn = db.startTransaction(false);
+	@Nullable
+	private StoredSession getSession(Transaction txn,
+			@Nullable SessionId sessionId) throws DbException, FormatException {
+		if (sessionId == null) return null;
+		BdfDictionary query = sessionParser.getSessionQuery(sessionId);
+		Map<MessageId, BdfDictionary> results = clientHelper
+				.getMessageMetadataAsDictionary(txn, localGroup.getId(), query);
+		if (results.size() > 1) throw new DbException();
+		if (results.isEmpty()) return null;
+		return new StoredSession(results.keySet().iterator().next(),
+				results.values().iterator().next());
+	}
+
+	private ContactId getContactId(Transaction txn, GroupId contactGroupId)
+			throws DbException, FormatException {
+		BdfDictionary meta =
+				clientHelper.getGroupMetadataAsDictionary(txn, contactGroupId);
+		return new ContactId(meta.getLong(GROUP_KEY_CONTACT_ID).intValue());
+	}
+
+	private MessageId createStorageId(Transaction txn) throws DbException {
+		Message m = clientHelper
+				.createMessageForStoringMetadata(localGroup.getId());
+		db.addLocalMessage(txn, m, new Metadata(), false);
+		return m.getId();
+	}
+
+	private void storeSession(Transaction txn, MessageId storageId,
+			Session session) throws DbException {
+		BdfDictionary d;
+		if (session.getRole() == INTRODUCER) {
+			d = sessionEncoder
+					.encodeIntroducerSession((IntroducerSession) session);
+		} else if (session.getRole() == INTRODUCEE) {
+			d = sessionEncoder
+					.encodeIntroduceeSession((IntroduceeSession) session);
+		} else {
+			throw new AssertionError();
+		}
+		try {
+			clientHelper.mergeMessageMetadata(txn, storageId, d);
+		} catch (FormatException e) {
+			throw new AssertionError();
+		}
+	}
+
+	@Override
+	public boolean canIntroduce(Contact c1, Contact c2) throws DbException {
+		Transaction txn = db.startTransaction(true);
 		try {
-			introducerManager.makeIntroduction(txn, c1, c2, msg, timestamp);
-			Group g1 = getContactGroup(c1);
-			Group g2 = getContactGroup(c2);
-			messageTracker.trackMessage(txn, g1.getId(), timestamp, true);
-			messageTracker.trackMessage(txn, g2.getId(), timestamp, true);
+			boolean can = canIntroduce(txn, c1, c2);
 			db.commitTransaction(txn);
+			return can;
+		} catch (FormatException e) {
+			throw new DbException(e);
 		} finally {
 			db.endTransaction(txn);
 		}
 	}
 
-	@Override
-	public void acceptIntroduction(ContactId contactId, SessionId sessionId,
-			long timestamp) throws DbException, FormatException {
+	private boolean canIntroduce(Transaction txn, Contact c1, Contact c2)
+			throws DbException, FormatException {
+		// Look up the session, if there is one
+		Author introducer = identityManager.getLocalAuthor(txn);
+		SessionId sessionId =
+				crypto.getSessionId(introducer, c1.getAuthor(),
+						c2.getAuthor());
+		StoredSession ss = getSession(txn, sessionId);
+		if (ss == null) return true;
+		IntroducerSession session =
+				sessionParser.parseIntroducerSession(ss.bdfSession);
+		return session.getState() == START;
+	}
 
+	@Override
+	public void makeIntroduction(Contact c1, Contact c2, @Nullable String msg,
+			long timestamp) throws DbException {
 		Transaction txn = db.startTransaction(false);
 		try {
-			Contact c = db.getContact(txn, contactId);
-			Group g = getContactGroup(c);
-			BdfDictionary state =
-					getSessionState(txn, g.getId(), sessionId.getBytes());
-
-			introduceeManager.acceptIntroduction(txn, state, timestamp);
-			messageTracker.trackMessage(txn, g.getId(), timestamp, true);
+			// Look up the session, if there is one
+			Author introducer = identityManager.getLocalAuthor(txn);
+			SessionId sessionId =
+					crypto.getSessionId(introducer, c1.getAuthor(),
+							c2.getAuthor());
+			StoredSession ss = getSession(txn, sessionId);
+			// Create or parse the session
+			IntroducerSession session;
+			MessageId storageId;
+			if (ss == null) {
+				// This is the first request - create a new session
+				GroupId groupId1 = getContactGroup(c1).getId();
+				GroupId groupId2 = getContactGroup(c2).getId();
+				boolean alice = crypto.isAlice(c1.getAuthor().getId(),
+						c2.getAuthor().getId());
+				// use fixed deterministic roles for the introducees
+				session = new IntroducerSession(sessionId,
+						alice ? groupId1 : groupId2,
+						alice ? c1.getAuthor() : c2.getAuthor(),
+						alice ? groupId2 : groupId1,
+						alice ? c2.getAuthor() : c1.getAuthor()
+				);
+				storageId = createStorageId(txn);
+			} else {
+				// An earlier request exists, so we already have a session
+				session = sessionParser.parseIntroducerSession(ss.bdfSession);
+				storageId = ss.storageId;
+			}
+			// Handle the request action
+			session = introducerEngine
+					.onRequestAction(txn, session, msg, timestamp);
+			// Store the updated session
+			storeSession(txn, storageId, session);
 			db.commitTransaction(txn);
+		} catch (FormatException e) {
+			throw new DbException(e);
 		} finally {
 			db.endTransaction(txn);
 		}
 	}
 
 	@Override
-	public void declineIntroduction(ContactId contactId, SessionId sessionId,
-			long timestamp) throws DbException, FormatException {
-
+	public void respondToIntroduction(ContactId contactId, SessionId sessionId,
+			long timestamp, boolean accept) throws DbException {
 		Transaction txn = db.startTransaction(false);
 		try {
-			Contact c = db.getContact(txn, contactId);
-			Group g = getContactGroup(c);
-			BdfDictionary state =
-					getSessionState(txn, g.getId(), sessionId.getBytes());
-
-			introduceeManager.declineIntroduction(txn, state, timestamp);
-			messageTracker.trackMessage(txn, g.getId(), timestamp, true);
+			// Look up the session
+			StoredSession ss = getSession(txn, sessionId);
+			if (ss == null) {
+				// Actions from the UI may be based on stale information.
+				// The contact might just have been deleted, for example.
+				// Throwing a DbException here aborts gracefully.
+				throw new DbException();
+			}
+			// Parse the session
+			Contact contact = db.getContact(txn, contactId);
+			GroupId contactGroupId = getContactGroup(contact).getId();
+			IntroduceeSession session = sessionParser
+					.parseIntroduceeSession(contactGroupId, ss.bdfSession);
+			// Handle the join or leave action
+			if (accept) {
+				session = introduceeEngine
+						.onAcceptAction(txn, session, timestamp);
+			} else {
+				session = introduceeEngine
+						.onDeclineAction(txn, session, timestamp);
+			}
+			// Store the updated session
+			storeSession(txn, ss.storageId, session);
 			db.commitTransaction(txn);
+		} catch (FormatException e) {
+			throw new DbException(e);
 		} finally {
 			db.endTransaction(txn);
 		}
 	}
 
 	@Override
-	public Collection<IntroductionMessage> getIntroductionMessages(
-			ContactId contactId) throws DbException {
-
-		Collection<IntroductionMessage> list = new ArrayList<>();
-
-		Map<MessageId, BdfDictionary> metadata;
-		Collection<MessageStatus> statuses;
+	public Collection<IntroductionMessage> getIntroductionMessages(ContactId c)
+			throws DbException {
+		List<IntroductionMessage> messages;
 		Transaction txn = db.startTransaction(true);
 		try {
-			// get messages and their status
-			GroupId g = getContactGroup(db.getContact(txn, contactId)).getId();
-			metadata = clientHelper.getMessageMetadataAsDictionary(txn, g);
-			statuses = db.getMessageStatus(txn, contactId, g);
-
-			// turn messages into classes for the UI
-			for (MessageStatus s : statuses) {
-				MessageId messageId = s.getMessageId();
-				BdfDictionary msg = metadata.get(messageId);
-				if (msg == null) continue;
-
-				try {
-					long type = msg.getLong(TYPE);
-					if (type == TYPE_ACK || type == TYPE_ABORT) continue;
-
-					// get session state
-					SessionId sessionId = new SessionId(msg.getRaw(SESSION_ID));
-					BdfDictionary state =
-							getSessionState(txn, g, sessionId.getBytes());
-
-					int role = state.getLong(ROLE).intValue();
-					boolean local;
-					long time = msg.getLong(MESSAGE_TIME);
-					boolean accepted = msg.getBoolean(ACCEPT, false);
-					boolean read = msg.getBoolean(MSG_KEY_READ, false);
-					AuthorId authorId;
-					String name;
-					if (type == TYPE_RESPONSE) {
-						if (role == ROLE_INTRODUCER) {
-							if (!concernsThisContact(contactId, messageId, state)) {
-								// this response is not from contactId
-								continue;
-							}
-							local = false;
-							authorId =
-									getAuthorIdForIntroducer(contactId, state);
-							name = getNameForIntroducer(contactId, state);
-						} else {
-							if (Arrays.equals(state.getRaw(NOT_OUR_RESPONSE),
-									messageId.getBytes())) {
-								// this response is not ours,
-								// check if it was a decline
-								if (!accepted) {
-									local = false;
-								} else {
-									// don't include positive responses
-									continue;
-								}
-							} else {
-								local = true;
-							}
-							authorId = new AuthorId(
-									state.getRaw(REMOTE_AUTHOR_ID));
-							name = state.getString(NAME);
-						}
-						IntroductionResponse ir = new IntroductionResponse(
-								sessionId, messageId, g, role, time, local,
-								s.isSent(), s.isSeen(), read, authorId, name,
-								accepted);
-						list.add(ir);
-					} else if (type == TYPE_REQUEST) {
-						String message;
-						boolean answered, exists, introducesOtherIdentity;
-						if (role == ROLE_INTRODUCER) {
-							local = true;
-							authorId =
-									getAuthorIdForIntroducer(contactId, state);
-							name = getNameForIntroducer(contactId, state);
-							message = msg.getOptionalString(MSG);
-							answered = false;
-							exists = false;
-							introducesOtherIdentity = false;
-						} else {
-							local = false;
-							authorId = new AuthorId(
-									state.getRaw(REMOTE_AUTHOR_ID));
-							name = state.getString(NAME);
-							message = state.getOptionalString(MSG);
-							boolean finished = state.getLong(STATE) ==
-									FINISHED.getValue();
-							answered = finished || state.getBoolean(ANSWERED);
-							exists = state.getBoolean(EXISTS);
-							introducesOtherIdentity =
-									state.getBoolean(REMOTE_AUTHOR_IS_US);
-						}
-						IntroductionRequest ir = new IntroductionRequest(
-								sessionId, messageId, g, role, time, local,
-								s.isSent(), s.isSeen(), read, authorId, name,
-								accepted, message, answered, exists,
-								introducesOtherIdentity);
-						list.add(ir);
-					}
-				} catch (FormatException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
+			Contact contact = db.getContact(txn, c);
+			GroupId contactGroupId = getContactGroup(contact).getId();
+			BdfDictionary query = messageParser.getMessagesVisibleInUiQuery();
+			Map<MessageId, BdfDictionary> results = clientHelper
+					.getMessageMetadataAsDictionary(txn, contactGroupId, query);
+			messages = new ArrayList<>(results.size());
+			for (Entry<MessageId, BdfDictionary> e : results.entrySet()) {
+				MessageId m = e.getKey();
+				MessageMetadata meta =
+						messageParser.parseMetadata(e.getValue());
+				MessageStatus status = db.getMessageStatus(txn, c, m);
+				StoredSession ss = getSession(txn, meta.getSessionId());
+				if (ss == null) throw new AssertionError();
+				MessageType type = meta.getMessageType();
+				if (type == REQUEST) {
+					messages.add(
+							parseInvitationRequest(txn, contactGroupId, m,
+									meta, status, ss.bdfSession));
+				} else if (type == ACCEPT) {
+					messages.add(
+							parseInvitationResponse(contactGroupId, m, meta,
+									status, ss.bdfSession, true));
+				} else if (type == DECLINE) {
+					messages.add(
+							parseInvitationResponse(contactGroupId, m, meta,
+									status, ss.bdfSession, false));
 				}
 			}
 			db.commitTransaction(txn);
@@ -438,88 +422,140 @@ class IntroductionManagerImpl extends ConversationClientImpl
 		} finally {
 			db.endTransaction(txn);
 		}
-		return list;
+		return messages;
 	}
 
-	private String getNameForIntroducer(ContactId contactId,
-			BdfDictionary state) throws FormatException {
-
-		if (contactId.getInt() == state.getLong(CONTACT_ID_1).intValue())
-			return state.getString(CONTACT_2);
-		if (contactId.getInt() == state.getLong(CONTACT_ID_2).intValue())
-			return state.getString(CONTACT_1);
-		throw new RuntimeException(
-				"Contact not part of this introduction session");
+	private IntroductionRequest parseInvitationRequest(Transaction txn,
+			GroupId contactGroupId, MessageId m, MessageMetadata meta,
+			MessageStatus status, BdfDictionary bdfSession)
+			throws DbException, FormatException {
+		Role role = sessionParser.getRole(bdfSession);
+		SessionId sessionId;
+		Author author;
+		if (role == INTRODUCER) {
+			IntroducerSession session =
+					sessionParser.parseIntroducerSession(bdfSession);
+			sessionId = session.getSessionId();
+			if (contactGroupId.equals(session.getIntroduceeA().groupId)) {
+				author = session.getIntroduceeB().author;
+			} else {
+				author = session.getIntroduceeA().author;
+			}
+		} else if (role == INTRODUCEE) {
+			IntroduceeSession session = sessionParser
+					.parseIntroduceeSession(contactGroupId, bdfSession);
+			sessionId = session.getSessionId();
+			author = session.getRemote().author;
+		} else throw new AssertionError();
+		Message msg = clientHelper.getMessage(txn, m);
+		if (msg == null) throw new AssertionError();
+		BdfList body = clientHelper.toList(msg);
+		RequestMessage rm = messageParser.parseRequestMessage(msg, body);
+		String message = rm.getMessage();
+		LocalAuthor localAuthor = identityManager.getLocalAuthor(txn);
+		boolean contactExists = contactManager
+				.contactExists(txn, rm.getAuthor().getId(),
+						localAuthor.getId());
+
+		return new IntroductionRequest(sessionId, m, contactGroupId,
+				role, meta.getTimestamp(), meta.isLocal(),
+				status.isSent(), status.isSeen(), meta.isRead(),
+				author.getName(), false, message, !meta.isAvailableToAnswer(),
+				contactExists);
 	}
 
-	private AuthorId getAuthorIdForIntroducer(ContactId contactId,
-			BdfDictionary state) throws FormatException {
-
-		if (contactId.getInt() == state.getLong(CONTACT_ID_1).intValue())
-			return new AuthorId(state.getRaw(AUTHOR_ID_2));
-		if (contactId.getInt() == state.getLong(CONTACT_ID_2).intValue())
-			return new AuthorId(state.getRaw(AUTHOR_ID_1));
-		throw new RuntimeException(
-				"Contact not part of this introduction session");
+	private IntroductionResponse parseInvitationResponse(GroupId contactGroupId,
+			MessageId m, MessageMetadata meta, MessageStatus status,
+			BdfDictionary bdfSession, boolean accept) throws FormatException {
+		Role role = sessionParser.getRole(bdfSession);
+		SessionId sessionId;
+		Author author;
+		if (role == INTRODUCER) {
+			IntroducerSession session =
+					sessionParser.parseIntroducerSession(bdfSession);
+			sessionId = session.getSessionId();
+			if (contactGroupId.equals(session.getIntroduceeA().groupId)) {
+				author = session.getIntroduceeB().author;
+			} else {
+				author = session.getIntroduceeA().author;
+			}
+		} else if (role == INTRODUCEE) {
+			IntroduceeSession session = sessionParser
+					.parseIntroduceeSession(contactGroupId, bdfSession);
+			sessionId = session.getSessionId();
+			author = session.getRemote().author;
+		} else throw new AssertionError();
+		return new IntroductionResponse(sessionId, m, contactGroupId,
+				role, meta.getTimestamp(), meta.isLocal(), status.isSent(),
+				status.isSeen(), meta.isRead(), author.getName(), accept);
 	}
 
-	private boolean concernsThisContact(ContactId contactId, MessageId messageId,
-			BdfDictionary state) throws FormatException {
-
-		if (contactId.getInt() == state.getLong(CONTACT_ID_1).intValue()) {
-			return Arrays.equals(state.getRaw(RESPONSE_1, new byte[0]),
-					messageId.getBytes());
-		} else {
-			return Arrays.equals(state.getRaw(RESPONSE_2, new byte[0]),
-					messageId.getBytes());
+	private void removeSessionWithIntroducer(Transaction txn,
+			Contact introducer) throws DbException {
+		BdfDictionary query = sessionEncoder
+				.getIntroduceeSessionsByIntroducerQuery(introducer.getAuthor());
+		Map<MessageId, BdfDictionary> sessions;
+		try {
+			sessions = clientHelper
+					.getMessageMetadataAsDictionary(txn, localGroup.getId(),
+							query);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+		for (MessageId id : sessions.keySet()) {
+			db.removeMessage(txn, id);
 		}
 	}
 
-	private BdfDictionary getSessionState(Transaction txn, GroupId groupId,
-			byte[] sessionId, boolean warn)
-			throws DbException, FormatException {
-
+	private void abortOrRemoveSessionWithIntroducee(Transaction txn,
+			Contact c) throws DbException {
+		BdfDictionary query = sessionEncoder.getIntroducerSessionsQuery();
+		Map<MessageId, BdfDictionary> sessions;
 		try {
-			// See if we can find the state directly for the introducer
-			BdfDictionary state = clientHelper
-					.getMessageMetadataAsDictionary(txn,
-							new MessageId(sessionId));
-			GroupId g1 = new GroupId(state.getRaw(GROUP_ID_1));
-			GroupId g2 = new GroupId(state.getRaw(GROUP_ID_2));
-			if (!g1.equals(groupId) && !g2.equals(groupId)) {
-				throw new NoSuchMessageException();
+			sessions = clientHelper
+					.getMessageMetadataAsDictionary(txn, localGroup.getId(),
+							query);
+		} catch (FormatException e) {
+			throw new DbException();
+		}
+		LocalAuthor localAuthor = identityManager.getLocalAuthor(txn);
+		for (Entry<MessageId, BdfDictionary> session : sessions.entrySet()) {
+			IntroducerSession s;
+			try {
+				s = sessionParser.parseIntroducerSession(session.getValue());
+			} catch (FormatException e) {
+				throw new DbException();
 			}
-			return state;
-		} catch (NoSuchMessageException e) {
-			// State not found directly, so iterate over all states
-			// to find state for introducee
-			Map<MessageId, BdfDictionary> map = clientHelper
-					.getMessageMetadataAsDictionary(txn,
-							introductionGroupFactory.createLocalGroup().getId());
-			for (Map.Entry<MessageId, BdfDictionary> m : map.entrySet()) {
-				if (Arrays.equals(m.getValue().getRaw(SESSION_ID), sessionId)) {
-					BdfDictionary state = m.getValue();
-					GroupId g = new GroupId(state.getRaw(GROUP_ID));
-					if (g.equals(groupId)) return state;
-				}
+			if (s.getIntroduceeA().author.equals(c.getAuthor())) {
+				abortOrRemoveSessionWithIntroducee(txn, s, session.getKey(),
+						s.getIntroduceeB(), localAuthor);
+			} else if (s.getIntroduceeB().author.equals(c.getAuthor())) {
+				abortOrRemoveSessionWithIntroducee(txn, s, session.getKey(),
+						s.getIntroduceeA(), localAuthor);
 			}
-			if (warn && LOG.isLoggable(WARNING))
-				LOG.warning("No session state found");
-			throw new FormatException();
 		}
 	}
 
-	private BdfDictionary getSessionState(Transaction txn, GroupId groupId,
-			byte[] sessionId) throws DbException, FormatException {
-
-		return getSessionState(txn, groupId, sessionId, true);
+	private void abortOrRemoveSessionWithIntroducee(Transaction txn,
+			IntroducerSession s, MessageId storageId, Introducee i,
+			LocalAuthor localAuthor) throws DbException {
+		if (db.containsContact(txn, i.author.getId(), localAuthor.getId())) {
+			IntroducerSession session = introducerEngine.onAbortAction(txn, s);
+			storeSession(txn, storageId, session);
+		} else {
+			db.removeMessage(txn, storageId);
+		}
 	}
 
-	private void deleteMessage(Transaction txn, MessageId messageId)
-			throws DbException {
+	private static class StoredSession {
+
+		private final MessageId storageId;
+		private final BdfDictionary bdfSession;
 
-		db.deleteMessage(txn, messageId);
-		db.deleteMessageMetadata(txn, messageId);
+		private StoredSession(MessageId storageId, BdfDictionary bdfSession) {
+			this.storageId = storageId;
+			this.bdfSession = bdfSession;
+		}
 	}
 
 }
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionModule.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionModule.java
index e0122faa9d315af728599a43220b89c4afdec756..24c649c5fd6cbb0e4a1fe7ad838af2ff13ac4d20 100644
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionModule.java
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionModule.java
@@ -4,8 +4,8 @@ import org.briarproject.bramble.api.client.ClientHelper;
 import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.data.MetadataEncoder;
 import org.briarproject.bramble.api.lifecycle.LifecycleManager;
+import org.briarproject.bramble.api.sync.ValidationManager;
 import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.api.client.MessageQueueManager;
 import org.briarproject.briar.api.introduction.IntroductionManager;
 import org.briarproject.briar.api.messaging.ConversationManager;
 
@@ -21,22 +21,22 @@ import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT
 public class IntroductionModule {
 
 	public static class EagerSingletons {
-		@Inject
-		IntroductionManager introductionManager;
 		@Inject
 		IntroductionValidator introductionValidator;
+		@Inject
+		IntroductionManager introductionManager;
 	}
 
 	@Provides
 	@Singleton
-	IntroductionValidator provideValidator(
-			MessageQueueManager messageQueueManager,
-			MetadataEncoder metadataEncoder, ClientHelper clientHelper,
-			Clock clock) {
-
-		IntroductionValidator introductionValidator = new IntroductionValidator(
-				clientHelper, metadataEncoder, clock);
-		messageQueueManager.registerMessageValidator(CLIENT_ID,
+	IntroductionValidator provideValidator(ValidationManager validationManager,
+			MessageEncoder messageEncoder, MetadataEncoder metadataEncoder,
+			ClientHelper clientHelper, Clock clock) {
+
+		IntroductionValidator introductionValidator =
+				new IntroductionValidator(messageEncoder, clientHelper,
+						metadataEncoder, clock);
+		validationManager.registerMessageValidator(CLIENT_ID,
 				introductionValidator);
 
 		return introductionValidator;
@@ -46,16 +46,42 @@ public class IntroductionModule {
 	@Singleton
 	IntroductionManager provideIntroductionManager(
 			LifecycleManager lifecycleManager, ContactManager contactManager,
-			MessageQueueManager messageQueueManager,
+			ValidationManager validationManager,
 			ConversationManager conversationManager,
 			IntroductionManagerImpl introductionManager) {
-
 		lifecycleManager.registerClient(introductionManager);
 		contactManager.registerContactHook(introductionManager);
-		messageQueueManager.registerIncomingMessageHook(CLIENT_ID,
+		validationManager.registerIncomingMessageHook(CLIENT_ID,
 				introductionManager);
 		conversationManager.registerConversationClient(introductionManager);
 
 		return introductionManager;
 	}
+
+	@Provides
+	MessageParser provideMessageParser(MessageParserImpl messageParser) {
+		return messageParser;
+	}
+
+	@Provides
+	MessageEncoder provideMessageEncoder(MessageEncoderImpl messageEncoder) {
+		return messageEncoder;
+	}
+
+	@Provides
+	SessionParser provideSessionParser(SessionParserImpl sessionParser) {
+		return sessionParser;
+	}
+
+	@Provides
+	SessionEncoder provideSessionEncoder(SessionEncoderImpl sessionEncoder) {
+		return sessionEncoder;
+	}
+
+	@Provides
+	IntroductionCrypto provideIntroductionCrypto(
+			IntroductionCryptoImpl introductionCrypto) {
+		return introductionCrypto;
+	}
+
 }
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionValidator.java b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionValidator.java
index 9fc526e49c24a709cc2a43d9abaea0b5973c944d..929c8bddf91798d3ca284ed9a42308edaa0a5e60 100644
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionValidator.java
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/IntroductionValidator.java
@@ -1,7 +1,9 @@
 package org.briarproject.briar.introduction;
 
 import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.UniqueId;
 import org.briarproject.bramble.api.client.BdfMessageContext;
+import org.briarproject.bramble.api.client.BdfMessageValidator;
 import org.briarproject.bramble.api.client.ClientHelper;
 import org.briarproject.bramble.api.data.BdfDictionary;
 import org.briarproject.bramble.api.data.BdfList;
@@ -9,183 +11,190 @@ import org.briarproject.bramble.api.data.MetadataEncoder;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.sync.Group;
 import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
 import org.briarproject.bramble.api.system.Clock;
 import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.client.BdfQueueMessageValidator;
+
+import java.util.Collections;
 
 import javax.annotation.concurrent.Immutable;
 
-import static org.briarproject.bramble.api.crypto.CryptoConstants.MAX_AGREEMENT_PUBLIC_KEY_BYTES;
-import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
+import static org.briarproject.bramble.api.crypto.CryptoConstants.MAC_BYTES;
+import static org.briarproject.bramble.api.crypto.CryptoConstants.MAX_SIGNATURE_BYTES;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
-import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
-import static org.briarproject.bramble.api.plugin.TransportId.MAX_TRANSPORT_ID_LENGTH;
-import static org.briarproject.bramble.api.properties.TransportPropertyConstants.MAX_PROPERTIES_PER_TRANSPORT;
-import static org.briarproject.bramble.api.properties.TransportPropertyConstants.MAX_PROPERTY_LENGTH;
 import static org.briarproject.bramble.util.ValidationUtils.checkLength;
 import static org.briarproject.bramble.util.ValidationUtils.checkSize;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_LENGTH;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_INTRODUCTION_MESSAGE_LENGTH;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_REQUEST_MESSAGE_LENGTH;
+import static org.briarproject.briar.introduction.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction.MessageType.ACTIVATE;
+import static org.briarproject.briar.introduction.MessageType.AUTH;
+
 
 @Immutable
 @NotNullByDefault
-class IntroductionValidator extends BdfQueueMessageValidator {
+class IntroductionValidator extends BdfMessageValidator {
 
-	IntroductionValidator(ClientHelper clientHelper,
-			MetadataEncoder metadataEncoder, Clock clock) {
+	private final MessageEncoder messageEncoder;
+
+	IntroductionValidator(MessageEncoder messageEncoder,
+			ClientHelper clientHelper, MetadataEncoder metadataEncoder,
+			Clock clock) {
 		super(clientHelper, metadataEncoder, clock);
+		this.messageEncoder = messageEncoder;
 	}
 
 	@Override
 	protected BdfMessageContext validateMessage(Message m, Group g,
 			BdfList body) throws FormatException {
+		MessageType type = MessageType.fromValue(body.getLong(0).intValue());
+
+		switch (type) {
+			case REQUEST:
+				return validateRequestMessage(m, body);
+			case ACCEPT:
+				return validateAcceptMessage(m, body);
+			case AUTH:
+				return validateAuthMessage(m, body);
+			case ACTIVATE:
+				return validateActivateMessage(m, body);
+			case DECLINE:
+			case ABORT:
+				return validateOtherMessage(type, m, body);
+			default:
+				throw new FormatException();
+		}
+	}
+
+	private BdfMessageContext validateRequestMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 4);
 
-		BdfDictionary d;
-		long type = body.getLong(0);
-		byte[] id = body.getRaw(1);
-		checkLength(id, SessionId.LENGTH);
-
-		if (type == TYPE_REQUEST) {
-			d = validateRequest(body);
-		} else if (type == TYPE_RESPONSE) {
-			d = validateResponse(body);
-		} else if (type == TYPE_ACK) {
-			d = validateAck(body);
-		} else if (type == TYPE_ABORT) {
-			d = validateAbort(body);
+		byte[] previousMessageId = body.getOptionalRaw(1);
+		checkLength(previousMessageId, UniqueId.LENGTH);
+
+		BdfList authorList = body.getList(2);
+		clientHelper.parseAndValidateAuthor(authorList);
+
+		String msg = body.getOptionalString(3);
+		checkLength(msg, 1, MAX_REQUEST_MESSAGE_LENGTH);
+
+		BdfDictionary meta =
+				messageEncoder.encodeRequestMetadata(m.getTimestamp());
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
 		} else {
-			throw new FormatException();
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
 		}
-
-		d.put(TYPE, type);
-		d.put(SESSION_ID, id);
-		d.put(GROUP_ID, m.getGroupId());
-		d.put(MESSAGE_ID, m.getId());
-		d.put(MESSAGE_TIME, m.getTimestamp());
-		return new BdfMessageContext(d);
 	}
 
-	private BdfDictionary validateRequest(BdfList message)
+	private BdfMessageContext validateAcceptMessage(Message m, BdfList body)
 			throws FormatException {
+		checkSize(body, 6);
 
-		checkSize(message, 4, 5);
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
 
-		// TODO: Exchange author format version
+		byte[] previousMessageId = body.getOptionalRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
 
-		// parse contact name
-		String name = message.getString(2);
-		checkLength(name, 1, MAX_AUTHOR_NAME_LENGTH);
+		byte[] ephemeralPublicKey = body.getRaw(3);
+		checkLength(ephemeralPublicKey, 0, MAX_PUBLIC_KEY_LENGTH);
 
-		// parse contact's public key
-		byte[] key = message.getRaw(3);
-		checkLength(key, 0, MAX_PUBLIC_KEY_LENGTH);
+		long timestamp = body.getLong(4);
+		if (timestamp < 0) throw new FormatException();
 
-		// parse (optional) message
-		String msg = null;
-		if (message.size() == 5) {
-			msg = message.getString(4);
-			checkLength(msg, 0, MAX_INTRODUCTION_MESSAGE_LENGTH);
-		}
+		BdfDictionary transportProperties = body.getDictionary(5);
+		if (transportProperties.size() < 1) throw new FormatException();
+		clientHelper
+				.parseAndValidateTransportPropertiesMap(transportProperties);
 
-		// Return the metadata
-		BdfDictionary d = new BdfDictionary();
-		d.put(NAME, name);
-		d.put(PUBLIC_KEY, key);
-		if (msg != null) {
-			d.put(MSG, msg);
+		SessionId sessionId = new SessionId(sessionIdBytes);
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(ACCEPT, sessionId, m.getTimestamp(), false,
+						false, false);
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
+		} else {
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
 		}
-		return d;
 	}
 
-	private BdfDictionary validateResponse(BdfList message)
+	private BdfMessageContext validateAuthMessage(Message m, BdfList body)
 			throws FormatException {
+		checkSize(body, 5);
 
-		checkSize(message, 3, 6);
-
-		// parse accept/decline
-		boolean accept = message.getBoolean(2);
-
-		long time = 0;
-		byte[] pubkey = null;
-		BdfDictionary tp = new BdfDictionary();
-		if (accept) {
-			checkSize(message, 6);
-
-			// parse timestamp
-			time = message.getLong(3);
-
-			// parse ephemeral public key
-			pubkey = message.getRaw(4);
-			checkLength(pubkey, 1, MAX_AGREEMENT_PUBLIC_KEY_BYTES);
-
-			// parse transport properties
-			tp = message.getDictionary(5);
-			if (tp.size() < 1) throw new FormatException();
-			for (String tId : tp.keySet()) {
-				checkLength(tId, 1, MAX_TRANSPORT_ID_LENGTH);
-				BdfDictionary tProps = tp.getDictionary(tId);
-				checkSize(tProps, 0, MAX_PROPERTIES_PER_TRANSPORT);
-				for (String propId : tProps.keySet()) {
-					checkLength(propId, 0, MAX_PROPERTY_LENGTH);
-					String prop = tProps.getString(propId);
-					checkLength(prop, 0, MAX_PROPERTY_LENGTH);
-				}
-			}
-		} else {
-			checkSize(message, 3);
-		}
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
 
-		// Return the metadata
-		BdfDictionary d = new BdfDictionary();
-		d.put(ACCEPT, accept);
-		if (accept) {
-			d.put(TIME, time);
-			d.put(E_PUBLIC_KEY, pubkey);
-			d.put(TRANSPORT, tp);
-		}
-		return d;
+		byte[] previousMessageId = body.getRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
+
+		byte[] mac = body.getRaw(3);
+		checkLength(mac, MAC_BYTES);
+
+		byte[] signature = body.getRaw(4);
+		checkLength(signature, 1, MAX_SIGNATURE_BYTES);
+
+		SessionId sessionId = new SessionId(sessionIdBytes);
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(AUTH, sessionId, m.getTimestamp(), false, false,
+						false);
+		MessageId dependency = new MessageId(previousMessageId);
+		return new BdfMessageContext(meta,
+				Collections.singletonList(dependency));
 	}
 
-	private BdfDictionary validateAck(BdfList message) throws FormatException {
-		checkSize(message, 4);
+	private BdfMessageContext validateActivateMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 4);
+
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
 
-		byte[] mac = message.getRaw(2);
-		checkLength(mac, 1, MAC_LENGTH);
+		byte[] previousMessageId = body.getRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
 
-		byte[] sig = message.getRaw(3);
-		checkLength(sig, 1, MAX_SIGNATURE_LENGTH);
+		byte[] mac = body.getOptionalRaw(3);
+		checkLength(mac, MAC_BYTES);
 
-		// Return the metadata
-		BdfDictionary d = new BdfDictionary();
-		d.put(MAC, mac);
-		d.put(SIGNATURE, sig);
-		return d;
+		SessionId sessionId = new SessionId(sessionIdBytes);
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(ACTIVATE, sessionId, m.getTimestamp(), false,
+						false, false);
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
+		} else {
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
+		}
 	}
 
-	private BdfDictionary validateAbort(BdfList message)
-			throws FormatException {
+	private BdfMessageContext validateOtherMessage(MessageType type,
+			Message m, BdfList body) throws FormatException {
+		checkSize(body, 3);
+
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
 
-		checkSize(message, 2);
+		byte[] previousMessageId = body.getOptionalRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
 
-		// Return the metadata
-		return new BdfDictionary();
+		SessionId sessionId = new SessionId(sessionIdBytes);
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(type, sessionId, m.getTimestamp(), false, false,
+						false);
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
+		} else {
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
+		}
 	}
+
 }
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageEncoder.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageEncoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..1327b54a938150a5c14019ae5db11a9dab162934
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageEncoder.java
@@ -0,0 +1,55 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+
+@NotNullByDefault
+interface MessageEncoder {
+
+	BdfDictionary encodeRequestMetadata(long timestamp);
+
+	BdfDictionary encodeMetadata(MessageType type,
+			@Nullable SessionId sessionId, long timestamp, boolean local,
+			boolean read, boolean visible);
+
+	void addSessionId(BdfDictionary meta, SessionId sessionId);
+
+	void setVisibleInUi(BdfDictionary meta, boolean visible);
+
+	void setAvailableToAnswer(BdfDictionary meta, boolean available);
+
+	Message encodeRequestMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, Author author,
+			@Nullable String message);
+
+	Message encodeAcceptMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId,
+			byte[] ephemeralPublicKey, long acceptTimestamp,
+			Map<TransportId, TransportProperties> transportProperties);
+
+	Message encodeDeclineMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId);
+
+	Message encodeAuthMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId,
+			byte[] mac, byte[] signature);
+
+	Message encodeActivateMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId,
+			byte[] mac);
+
+	Message encodeAbortMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId);
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageEncoderImpl.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageEncoderImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..fb3d66f38012129401d21453d63b9ab431be13d1
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageEncoderImpl.java
@@ -0,0 +1,183 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageFactory;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static org.briarproject.briar.client.MessageTrackerConstants.MSG_KEY_READ;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_AVAILABLE_TO_ANSWER;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_LOCAL;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_MESSAGE_TYPE;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_SESSION_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_VISIBLE_IN_UI;
+import static org.briarproject.briar.introduction.MessageType.ABORT;
+import static org.briarproject.briar.introduction.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction.MessageType.ACTIVATE;
+import static org.briarproject.briar.introduction.MessageType.AUTH;
+import static org.briarproject.briar.introduction.MessageType.DECLINE;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
+
+@NotNullByDefault
+class MessageEncoderImpl implements MessageEncoder {
+
+	private final ClientHelper clientHelper;
+	private final MessageFactory messageFactory;
+
+	@Inject
+	MessageEncoderImpl(ClientHelper clientHelper,
+			MessageFactory messageFactory) {
+		this.clientHelper = clientHelper;
+		this.messageFactory = messageFactory;
+	}
+
+	@Override
+	public BdfDictionary encodeRequestMetadata(long timestamp) {
+		BdfDictionary meta =
+				encodeMetadata(REQUEST, null, timestamp, false, false, false);
+		meta.put(MSG_KEY_AVAILABLE_TO_ANSWER, false);
+		return meta;
+	}
+
+	@Override
+	public BdfDictionary encodeMetadata(MessageType type,
+			@Nullable SessionId sessionId, long timestamp, boolean local,
+			boolean read, boolean visible) {
+		BdfDictionary meta = new BdfDictionary();
+		meta.put(MSG_KEY_MESSAGE_TYPE, type.getValue());
+		if (sessionId != null)
+			meta.put(MSG_KEY_SESSION_ID, sessionId);
+		else if (type != REQUEST)
+			throw new IllegalArgumentException();
+		meta.put(MSG_KEY_TIMESTAMP, timestamp);
+		meta.put(MSG_KEY_LOCAL, local);
+		meta.put(MSG_KEY_READ, read);
+		meta.put(MSG_KEY_VISIBLE_IN_UI, visible);
+		return meta;
+	}
+
+	@Override
+	public void addSessionId(BdfDictionary meta, SessionId sessionId) {
+		meta.put(MSG_KEY_SESSION_ID, sessionId);
+	}
+
+	@Override
+	public void setVisibleInUi(BdfDictionary meta, boolean visible) {
+		meta.put(MSG_KEY_VISIBLE_IN_UI, visible);
+	}
+
+	@Override
+	public void setAvailableToAnswer(BdfDictionary meta, boolean available) {
+		meta.put(MSG_KEY_AVAILABLE_TO_ANSWER, available);
+	}
+
+	@Override
+	public Message encodeRequestMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, Author author,
+			@Nullable String message) {
+		if (message != null && message.equals("")) {
+			throw new IllegalArgumentException();
+		}
+		BdfList body = BdfList.of(
+				REQUEST.getValue(),
+				previousMessageId,
+				clientHelper.toList(author),
+				message
+		);
+		return createMessage(contactGroupId, timestamp, body);
+	}
+
+	@Override
+	public Message encodeAcceptMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId,
+			byte[] ephemeralPublicKey, long acceptTimestamp,
+			Map<TransportId, TransportProperties> transportProperties) {
+		BdfList body = BdfList.of(
+				ACCEPT.getValue(),
+				sessionId,
+				previousMessageId,
+				ephemeralPublicKey,
+				acceptTimestamp,
+				clientHelper.toDictionary(transportProperties)
+		);
+		return createMessage(contactGroupId, timestamp, body);
+	}
+
+	@Override
+	public Message encodeDeclineMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId) {
+		return encodeMessage(DECLINE, contactGroupId, sessionId, timestamp,
+				previousMessageId);
+	}
+
+	@Override
+	public Message encodeAuthMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId,
+			byte[] mac, byte[] signature) {
+		BdfList body = BdfList.of(
+				AUTH.getValue(),
+				sessionId,
+				previousMessageId,
+				mac,
+				signature
+		);
+		return createMessage(contactGroupId, timestamp, body);
+	}
+
+	@Override
+	public Message encodeActivateMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId,
+			byte[] mac) {
+		BdfList body = BdfList.of(
+				ACTIVATE.getValue(),
+				sessionId,
+				previousMessageId,
+				mac
+		);
+		return createMessage(contactGroupId, timestamp, body);
+	}
+
+	@Override
+	public Message encodeAbortMessage(GroupId contactGroupId, long timestamp,
+			@Nullable MessageId previousMessageId, SessionId sessionId) {
+		return encodeMessage(ABORT, contactGroupId, sessionId, timestamp,
+				previousMessageId);
+	}
+
+	private Message encodeMessage(MessageType type, GroupId contactGroupId,
+			SessionId sessionId, long timestamp,
+			@Nullable MessageId previousMessageId) {
+		BdfList body = BdfList.of(
+				type.getValue(),
+				sessionId,
+				previousMessageId
+		);
+		return createMessage(contactGroupId, timestamp, body);
+	}
+
+	private Message createMessage(GroupId contactGroupId, long timestamp,
+			BdfList body) {
+		try {
+			return messageFactory.createMessage(contactGroupId, timestamp,
+					clientHelper.toByteArray(body));
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageMetadata.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageMetadata.java
new file mode 100644
index 0000000000000000000000000000000000000000..102d72bfc3fc0634c39a915f41c9efc2cfa1cd7b
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageMetadata.java
@@ -0,0 +1,60 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.briar.api.client.SessionId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class MessageMetadata {
+
+	private final MessageType type;
+	@Nullable
+	private final SessionId sessionId;
+	private final long timestamp;
+	private final boolean local, read, visible, available;
+
+	MessageMetadata(MessageType type, @Nullable SessionId sessionId,
+			long timestamp, boolean local, boolean read, boolean visible,
+			boolean available) {
+		this.type = type;
+		this.sessionId = sessionId;
+		this.timestamp = timestamp;
+		this.local = local;
+		this.read = read;
+		this.visible = visible;
+		this.available = available;
+	}
+
+	MessageType getMessageType() {
+		return type;
+	}
+
+	@Nullable
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+	long getTimestamp() {
+		return timestamp;
+	}
+
+	boolean isLocal() {
+		return local;
+	}
+
+	boolean isRead() {
+		return read;
+	}
+
+	boolean isVisibleInConversation() {
+		return visible;
+	}
+
+	boolean isAvailableToAnswer() {
+		return available;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageParser.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..503dd4cc66077228053a147ac1e5dc92b546cfee
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageParser.java
@@ -0,0 +1,37 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.briar.api.client.SessionId;
+
+@NotNullByDefault
+interface MessageParser {
+
+	BdfDictionary getMessagesVisibleInUiQuery();
+
+	BdfDictionary getRequestsAvailableToAnswerQuery(SessionId sessionId);
+
+	MessageMetadata parseMetadata(BdfDictionary meta) throws FormatException;
+
+	RequestMessage parseRequestMessage(Message m, BdfList body)
+			throws FormatException;
+
+	AcceptMessage parseAcceptMessage(Message m, BdfList body)
+			throws FormatException;
+
+	DeclineMessage parseDeclineMessage(Message m, BdfList body)
+			throws FormatException;
+
+	AuthMessage parseAuthMessage(Message m, BdfList body)
+			throws FormatException;
+
+	ActivateMessage parseActivateMessage(Message m, BdfList body)
+			throws FormatException;
+
+	AbortMessage parseAbortMessage(Message m, BdfList body)
+			throws FormatException;
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageParserImpl.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageParserImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..69ddd242d1cab3137d7e312905d16388362a629f
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageParserImpl.java
@@ -0,0 +1,143 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfEntry;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import static org.briarproject.briar.client.MessageTrackerConstants.MSG_KEY_READ;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_AVAILABLE_TO_ANSWER;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_LOCAL;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_MESSAGE_TYPE;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_SESSION_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_VISIBLE_IN_UI;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
+
+@NotNullByDefault
+class MessageParserImpl implements MessageParser {
+
+	private final ClientHelper clientHelper;
+
+	@Inject
+	MessageParserImpl(ClientHelper clientHelper) {
+		this.clientHelper = clientHelper;
+	}
+
+	@Override
+	public BdfDictionary getMessagesVisibleInUiQuery() {
+		return BdfDictionary.of(new BdfEntry(MSG_KEY_VISIBLE_IN_UI, true));
+	}
+
+	@Override
+	public BdfDictionary getRequestsAvailableToAnswerQuery(SessionId sessionId) {
+		return BdfDictionary.of(
+				new BdfEntry(MSG_KEY_AVAILABLE_TO_ANSWER, true),
+				new BdfEntry(MSG_KEY_MESSAGE_TYPE, REQUEST.getValue()),
+				new BdfEntry(MSG_KEY_SESSION_ID, sessionId)
+		);
+	}
+
+	@Override
+	public MessageMetadata parseMetadata(BdfDictionary d)
+			throws FormatException {
+		MessageType type = MessageType
+				.fromValue(d.getLong(MSG_KEY_MESSAGE_TYPE).intValue());
+		byte[] sessionIdBytes = d.getOptionalRaw(MSG_KEY_SESSION_ID);
+		SessionId sessionId =
+				sessionIdBytes == null ? null : new SessionId(sessionIdBytes);
+		long timestamp = d.getLong(MSG_KEY_TIMESTAMP);
+		boolean local = d.getBoolean(MSG_KEY_LOCAL);
+		boolean read = d.getBoolean(MSG_KEY_READ);
+		boolean visible = d.getBoolean(MSG_KEY_VISIBLE_IN_UI);
+		boolean available = d.getBoolean(MSG_KEY_AVAILABLE_TO_ANSWER, false);
+		return new MessageMetadata(type, sessionId, timestamp, local, read,
+				visible, available);
+	}
+
+	@Override
+	public RequestMessage parseRequestMessage(Message m, BdfList body)
+			throws FormatException {
+		byte[] previousMsgBytes = body.getOptionalRaw(1);
+		MessageId previousMessageId = (previousMsgBytes == null ? null :
+				new MessageId(previousMsgBytes));
+		Author author = clientHelper.parseAndValidateAuthor(body.getList(2));
+		String message = body.getOptionalString(3);
+		return new RequestMessage(m.getId(), m.getGroupId(),
+				m.getTimestamp(), previousMessageId, author, message);
+	}
+
+	@Override
+	public AcceptMessage parseAcceptMessage(Message m, BdfList body)
+			throws FormatException {
+		SessionId sessionId = new SessionId(body.getRaw(1));
+		byte[] previousMsgBytes = body.getOptionalRaw(2);
+		MessageId previousMessageId = (previousMsgBytes == null ? null :
+				new MessageId(previousMsgBytes));
+		byte[] ephemeralPublicKey = body.getRaw(3);
+		long acceptTimestamp = body.getLong(4);
+		Map<TransportId, TransportProperties> transportProperties = clientHelper
+				.parseAndValidateTransportPropertiesMap(body.getDictionary(5));
+		return new AcceptMessage(m.getId(), m.getGroupId(), m.getTimestamp(),
+				previousMessageId, sessionId, ephemeralPublicKey,
+				acceptTimestamp, transportProperties);
+	}
+
+	@Override
+	public DeclineMessage parseDeclineMessage(Message m, BdfList body)
+			throws FormatException {
+		SessionId sessionId = new SessionId(body.getRaw(1));
+		byte[] previousMsgBytes = body.getOptionalRaw(2);
+		MessageId previousMessageId = (previousMsgBytes == null ? null :
+				new MessageId(previousMsgBytes));
+		return new DeclineMessage(m.getId(), m.getGroupId(), m.getTimestamp(),
+				previousMessageId, sessionId);
+	}
+
+	@Override
+	public AuthMessage parseAuthMessage(Message m, BdfList body)
+			throws FormatException {
+		SessionId sessionId = new SessionId(body.getRaw(1));
+		byte[] previousMsgBytes = body.getRaw(2);
+		MessageId previousMessageId = new MessageId(previousMsgBytes);
+		byte[] mac = body.getRaw(3);
+		byte[] signature = body.getRaw(4);
+		return new AuthMessage(m.getId(), m.getGroupId(), m.getTimestamp(),
+				previousMessageId, sessionId, mac, signature);
+	}
+
+	@Override
+	public ActivateMessage parseActivateMessage(Message m, BdfList body)
+			throws FormatException {
+		SessionId sessionId = new SessionId(body.getRaw(1));
+		byte[] previousMsgBytes = body.getRaw(2);
+		MessageId previousMessageId = new MessageId(previousMsgBytes);
+		byte[] mac = body.getRaw(3);
+		return new ActivateMessage(m.getId(), m.getGroupId(), m.getTimestamp(),
+				previousMessageId, sessionId, mac);
+	}
+
+	@Override
+	public AbortMessage parseAbortMessage(Message m, BdfList body)
+			throws FormatException {
+		SessionId sessionId = new SessionId(body.getRaw(1));
+		byte[] previousMsgBytes = body.getOptionalRaw(2);
+		MessageId previousMessageId = (previousMsgBytes == null ? null :
+				new MessageId(previousMsgBytes));
+		return new AbortMessage(m.getId(), m.getGroupId(), m.getTimestamp(),
+				previousMessageId, sessionId);
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageSender.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageSender.java
deleted file mode 100644
index 7848aaf3dcd4fadba978f7cf576e763873c174d5..0000000000000000000000000000000000000000
--- a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageSender.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.data.MetadataEncoder;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Metadata;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.api.client.MessageQueueManager;
-
-import javax.annotation.concurrent.Immutable;
-import javax.inject.Inject;
-
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-
-@Immutable
-@NotNullByDefault
-class MessageSender {
-
-	private final DatabaseComponent db;
-	private final ClientHelper clientHelper;
-	private final Clock clock;
-	private final MetadataEncoder metadataEncoder;
-	private final MessageQueueManager messageQueueManager;
-
-	@Inject
-	MessageSender(DatabaseComponent db, ClientHelper clientHelper, Clock clock,
-			MetadataEncoder metadataEncoder,
-			MessageQueueManager messageQueueManager) {
-
-		this.db = db;
-		this.clientHelper = clientHelper;
-		this.clock = clock;
-		this.metadataEncoder = metadataEncoder;
-		this.messageQueueManager = messageQueueManager;
-	}
-
-	void sendMessage(Transaction txn, BdfDictionary message)
-			throws DbException, FormatException {
-
-		BdfList bdfList = encodeMessage(message);
-		byte[] body = clientHelper.toByteArray(bdfList);
-		GroupId groupId = new GroupId(message.getRaw(GROUP_ID));
-		Group group = db.getGroup(txn, groupId);
-		long timestamp = clock.currentTimeMillis();
-
-		message.put(MESSAGE_TIME, timestamp);
-		Metadata metadata = metadataEncoder.encode(message);
-
-		messageQueueManager.sendMessage(txn, group, timestamp, body, metadata);
-	}
-
-	private BdfList encodeMessage(BdfDictionary d) throws FormatException {
-
-		BdfList body;
-		long type = d.getLong(TYPE);
-		if (type == TYPE_REQUEST) {
-			body = encodeRequest(d);
-		} else if (type == TYPE_RESPONSE) {
-			body = encodeResponse(d);
-		} else if (type == TYPE_ACK) {
-			body = encodeAck(d);
-		} else if (type == TYPE_ABORT) {
-			body = encodeAbort(d);
-		} else {
-			throw new FormatException();
-		}
-		return body;
-	}
-
-	private BdfList encodeRequest(BdfDictionary d) throws FormatException {
-		BdfList list = BdfList.of(TYPE_REQUEST, d.getRaw(SESSION_ID),
-				d.getString(NAME), d.getRaw(PUBLIC_KEY));
-
-		if (d.containsKey(MSG)) {
-			list.add(d.getString(MSG));
-		}
-		return list;
-	}
-
-	private BdfList encodeResponse(BdfDictionary d) throws FormatException {
-		BdfList list = BdfList.of(TYPE_RESPONSE, d.getRaw(SESSION_ID),
-				d.getBoolean(ACCEPT));
-
-		if (d.getBoolean(ACCEPT)) {
-			list.add(d.getLong(TIME));
-			list.add(d.getRaw(E_PUBLIC_KEY));
-			list.add(d.getDictionary(TRANSPORT));
-		}
-		return list;
-	}
-
-	private BdfList encodeAck(BdfDictionary d) throws FormatException {
-		return BdfList.of(TYPE_ACK, d.getRaw(SESSION_ID), d.getRaw(MAC),
-				d.getRaw(SIGNATURE));
-	}
-
-	private BdfList encodeAbort(BdfDictionary d) throws FormatException {
-		return BdfList.of(TYPE_ABORT, d.getRaw(SESSION_ID));
-	}
-
-}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/MessageType.java b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageType.java
new file mode 100644
index 0000000000000000000000000000000000000000..67365399b0bc22a8627bdf6bba31355116be22ab
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/MessageType.java
@@ -0,0 +1,29 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+enum MessageType {
+
+	REQUEST(0), ACCEPT(1), DECLINE(2), AUTH(3), ACTIVATE(4), ABORT(5);
+
+	private final int value;
+
+	MessageType(int value) {
+		this.value = value;
+	}
+
+	int getValue() {
+		return value;
+	}
+
+	static MessageType fromValue(int value) throws FormatException {
+		for (MessageType m : values()) if (m.value == value) return m;
+		throw new FormatException();
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/PeerSession.java b/briar-core/src/main/java/org/briarproject/briar/introduction/PeerSession.java
new file mode 100644
index 0000000000000000000000000000000000000000..3c453d2fa9e8cc311623768c48362f65b197a2ad
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/PeerSession.java
@@ -0,0 +1,25 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.SessionId;
+
+import javax.annotation.Nullable;
+
+@NotNullByDefault
+interface PeerSession {
+
+	SessionId getSessionId();
+
+	GroupId getContactGroupId();
+
+	long getLocalTimestamp();
+
+	@Nullable
+	MessageId getLastLocalMessageId();
+
+	@Nullable
+	MessageId getLastRemoteMessageId();
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/ProtocolEngine.java b/briar-core/src/main/java/org/briarproject/briar/introduction/ProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..e3766c91a4556b3bde9e40c2371079a229ef4da7
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/ProtocolEngine.java
@@ -0,0 +1,40 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.Nullable;
+
+@NotNullByDefault
+interface ProtocolEngine<S extends Session> {
+
+	S onRequestAction(Transaction txn, S session, @Nullable String message,
+			long timestamp) throws DbException;
+
+	S onAcceptAction(Transaction txn, S session, long timestamp)
+			throws DbException;
+
+	S onDeclineAction(Transaction txn, S session, long timestamp)
+			throws DbException;
+
+	S onRequestMessage(Transaction txn, S session, RequestMessage m)
+			throws DbException, FormatException;
+
+	S onAcceptMessage(Transaction txn, S session, AcceptMessage m)
+			throws DbException, FormatException;
+
+	S onDeclineMessage(Transaction txn, S session, DeclineMessage m)
+			throws DbException, FormatException;
+
+	S onAuthMessage(Transaction txn, S session, AuthMessage m)
+			throws DbException, FormatException;
+
+	S onActivateMessage(Transaction txn, S session, ActivateMessage m)
+			throws DbException, FormatException;
+
+	S onAbortMessage(Transaction txn, S session, AbortMessage m)
+			throws DbException, FormatException;
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/RequestMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction/RequestMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..743e87af872d898f8535b9311d60eb9650622562
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/RequestMessage.java
@@ -0,0 +1,36 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class RequestMessage extends AbstractIntroductionMessage {
+
+	private final Author author;
+	@Nullable
+	private final String message;
+
+	protected RequestMessage(MessageId messageId, GroupId groupId,
+			long timestamp, @Nullable MessageId previousMessageId,
+			Author author, @Nullable String message) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.author = author;
+		this.message = message;
+	}
+
+	public Author getAuthor() {
+		return author;
+	}
+
+	@Nullable
+	public String getMessage() {
+		return message;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/Session.java b/briar-core/src/main/java/org/briarproject/briar/introduction/Session.java
new file mode 100644
index 0000000000000000000000000000000000000000..086dfb1a2975defaf177206dbce5cb65cac7edcb
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/Session.java
@@ -0,0 +1,37 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.Role;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+abstract class Session<S extends State> {
+
+	private final SessionId sessionId;
+	private final S state;
+	private final long requestTimestamp;
+
+	Session(SessionId sessionId, S state, long requestTimestamp) {
+		this.sessionId = sessionId;
+		this.state = state;
+		this.requestTimestamp = requestTimestamp;
+	}
+
+	abstract Role getRole();
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+	S getState() {
+		return state;
+	}
+
+	long getRequestTimestamp() {
+		return requestTimestamp;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/SessionEncoder.java b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionEncoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..70cfff1bba1077426bc4e087cf60a6627829ae3b
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionEncoder.java
@@ -0,0 +1,18 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+@NotNullByDefault
+interface SessionEncoder {
+
+	BdfDictionary getIntroduceeSessionsByIntroducerQuery(Author introducer);
+
+	BdfDictionary getIntroducerSessionsQuery();
+
+	BdfDictionary encodeIntroducerSession(IntroducerSession s);
+
+	BdfDictionary encodeIntroduceeSession(IntroduceeSession s);
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/SessionEncoderImpl.java b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionEncoderImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..9023f0d94d3ebb466b9229038766c7d903dd6bf4
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionEncoderImpl.java
@@ -0,0 +1,159 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfEntry;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.briar.introduction.IntroduceeSession.Common;
+import org.briarproject.briar.introduction.IntroduceeSession.Local;
+import org.briarproject.briar.introduction.IntroduceeSession.Remote;
+import org.briarproject.briar.introduction.IntroducerSession.Introducee;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.bramble.api.data.BdfDictionary.NULL_VALUE;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCEE;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCER;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ACCEPT_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ALICE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_AUTHOR;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_EPHEMERAL_PRIVATE_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_EPHEMERAL_PUBLIC_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_GROUP_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCEE_A;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCEE_B;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCER;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LAST_LOCAL_MESSAGE_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LAST_REMOTE_MESSAGE_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LOCAL;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LOCAL_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_MAC_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_MASTER_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_REMOTE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_REMOTE_AUTHOR;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_REQUEST_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ROLE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_SESSION_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_STATE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_TRANSPORT_KEYS;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_TRANSPORT_PROPERTIES;
+
+@Immutable
+@NotNullByDefault
+class SessionEncoderImpl implements SessionEncoder {
+
+	private final ClientHelper clientHelper;
+
+	@Inject
+	SessionEncoderImpl(ClientHelper clientHelper) {
+		this.clientHelper = clientHelper;
+	}
+
+	@Override
+	public BdfDictionary getIntroduceeSessionsByIntroducerQuery(
+			Author introducer) {
+		return BdfDictionary.of(
+				new BdfEntry(SESSION_KEY_ROLE, INTRODUCEE.getValue()),
+				new BdfEntry(SESSION_KEY_INTRODUCER,
+						clientHelper.toList(introducer))
+		);
+	}
+
+	@Override
+	public BdfDictionary getIntroducerSessionsQuery() {
+		return BdfDictionary.of(
+				new BdfEntry(SESSION_KEY_ROLE, INTRODUCER.getValue())
+		);
+	}
+
+	@Override
+	public BdfDictionary encodeIntroducerSession(IntroducerSession s) {
+		BdfDictionary d = encodeSession(s);
+		d.put(SESSION_KEY_INTRODUCEE_A, encodeIntroducee(s.getIntroduceeA()));
+		d.put(SESSION_KEY_INTRODUCEE_B, encodeIntroducee(s.getIntroduceeB()));
+		return d;
+	}
+
+	private BdfDictionary encodeIntroducee(Introducee i) {
+		BdfDictionary d = new BdfDictionary();
+		putNullable(d, SESSION_KEY_LAST_LOCAL_MESSAGE_ID, i.lastLocalMessageId);
+		putNullable(d, SESSION_KEY_LAST_REMOTE_MESSAGE_ID,
+				i.lastRemoteMessageId);
+		d.put(SESSION_KEY_LOCAL_TIMESTAMP, i.localTimestamp);
+		d.put(SESSION_KEY_GROUP_ID, i.groupId);
+		d.put(SESSION_KEY_AUTHOR, clientHelper.toList(i.author));
+		return d;
+	}
+
+	@Override
+	public BdfDictionary encodeIntroduceeSession(IntroduceeSession s) {
+		BdfDictionary d = encodeSession(s);
+		d.put(SESSION_KEY_INTRODUCER, clientHelper.toList(s.getIntroducer()));
+		d.put(SESSION_KEY_LOCAL, encodeLocal(s.getLocal()));
+		d.put(SESSION_KEY_REMOTE, encodeRemote(s.getRemote()));
+		putNullable(d, SESSION_KEY_MASTER_KEY, s.getMasterKey());
+		putNullable(d, SESSION_KEY_TRANSPORT_KEYS,
+				encodeTransportKeys(s.getTransportKeys()));
+		return d;
+	}
+
+	private BdfDictionary encodeCommon(Common s) {
+		BdfDictionary d = new BdfDictionary();
+		d.put(SESSION_KEY_ALICE, s.alice);
+		putNullable(d, SESSION_KEY_EPHEMERAL_PUBLIC_KEY, s.ephemeralPublicKey);
+		putNullable(d, SESSION_KEY_TRANSPORT_PROPERTIES,
+				s.transportProperties == null ? null :
+						clientHelper.toDictionary(s.transportProperties));
+		d.put(SESSION_KEY_ACCEPT_TIMESTAMP, s.acceptTimestamp);
+		putNullable(d, SESSION_KEY_MAC_KEY, s.macKey);
+		return d;
+	}
+
+	private BdfDictionary encodeLocal(Local s) {
+		BdfDictionary d = encodeCommon(s);
+		d.put(SESSION_KEY_LOCAL_TIMESTAMP, s.lastMessageTimestamp);
+		putNullable(d, SESSION_KEY_LAST_LOCAL_MESSAGE_ID, s.lastMessageId);
+		putNullable(d, SESSION_KEY_EPHEMERAL_PRIVATE_KEY,
+				s.ephemeralPrivateKey);
+		return d;
+	}
+
+	private BdfDictionary encodeRemote(Remote s) {
+		BdfDictionary d = encodeCommon(s);
+		d.put(SESSION_KEY_REMOTE_AUTHOR, clientHelper.toList(s.author));
+		putNullable(d, SESSION_KEY_LAST_REMOTE_MESSAGE_ID, s.lastMessageId);
+		return d;
+	}
+
+	private BdfDictionary encodeSession(Session s) {
+		BdfDictionary d = new BdfDictionary();
+		d.put(SESSION_KEY_SESSION_ID, s.getSessionId());
+		d.put(SESSION_KEY_ROLE, s.getRole().getValue());
+		d.put(SESSION_KEY_STATE, s.getState().getValue());
+		d.put(SESSION_KEY_REQUEST_TIMESTAMP, s.getRequestTimestamp());
+		return d;
+	}
+
+	@Nullable
+	private BdfDictionary encodeTransportKeys(
+			@Nullable Map<TransportId, KeySetId> keys) {
+		if (keys == null) return null;
+		BdfDictionary d = new BdfDictionary();
+		for (Map.Entry<TransportId, KeySetId> e : keys.entrySet()) {
+			d.put(e.getKey().getString(), e.getValue().getInt());
+		}
+		return d;
+	}
+
+	private void putNullable(BdfDictionary d, String key, @Nullable Object o) {
+		d.put(key, o == null ? NULL_VALUE : o);
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/SessionParser.java b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..c58cac3d951012cbd1298f2283b97e2747f13681
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionParser.java
@@ -0,0 +1,23 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.Role;
+
+@NotNullByDefault
+interface SessionParser {
+
+	BdfDictionary getSessionQuery(SessionId s);
+
+	Role getRole(BdfDictionary d) throws FormatException;
+
+	IntroducerSession parseIntroducerSession(BdfDictionary d)
+			throws FormatException;
+
+	IntroduceeSession parseIntroduceeSession(GroupId introducerGroupId,
+			BdfDictionary d) throws FormatException;
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/SessionParserImpl.java b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionParserImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..52c12e5477ae040cc77be6193eeed5108d600662
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/SessionParserImpl.java
@@ -0,0 +1,199 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfEntry;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.api.introduction.Role;
+import org.briarproject.briar.introduction.IntroduceeSession.Local;
+import org.briarproject.briar.introduction.IntroduceeSession.Remote;
+import org.briarproject.briar.introduction.IntroducerSession.Introducee;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.briar.api.introduction.Role.INTRODUCEE;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCER;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ACCEPT_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ALICE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_AUTHOR;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_EPHEMERAL_PRIVATE_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_EPHEMERAL_PUBLIC_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_GROUP_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCEE_A;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCEE_B;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCER;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LAST_LOCAL_MESSAGE_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LAST_REMOTE_MESSAGE_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LOCAL;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LOCAL_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_MAC_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_MASTER_KEY;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_REMOTE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_REMOTE_AUTHOR;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_REQUEST_TIMESTAMP;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ROLE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_SESSION_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_STATE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_TRANSPORT_KEYS;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_TRANSPORT_PROPERTIES;
+
+@Immutable
+@NotNullByDefault
+class SessionParserImpl implements SessionParser {
+
+	private final ClientHelper clientHelper;
+
+	@Inject
+	SessionParserImpl(ClientHelper clientHelper) {
+		this.clientHelper = clientHelper;
+	}
+
+	@Override
+	public BdfDictionary getSessionQuery(SessionId s) {
+		return BdfDictionary.of(new BdfEntry(SESSION_KEY_SESSION_ID, s));
+	}
+
+	@Override
+	public Role getRole(BdfDictionary d) throws FormatException {
+		return Role.fromValue(d.getLong(SESSION_KEY_ROLE).intValue());
+	}
+
+	@Override
+	public IntroducerSession parseIntroducerSession(BdfDictionary d)
+			throws FormatException {
+		if (getRole(d) != INTRODUCER) throw new IllegalArgumentException();
+		SessionId sessionId = getSessionId(d);
+		IntroducerState state = IntroducerState.fromValue(getState(d));
+		long requestTimestamp = d.getLong(SESSION_KEY_REQUEST_TIMESTAMP);
+		Introducee introduceeA = parseIntroducee(sessionId,
+				d.getDictionary(SESSION_KEY_INTRODUCEE_A));
+		Introducee introduceeB = parseIntroducee(sessionId,
+				d.getDictionary(SESSION_KEY_INTRODUCEE_B));
+		return new IntroducerSession(sessionId, state, requestTimestamp,
+				introduceeA, introduceeB);
+	}
+
+	private Introducee parseIntroducee(SessionId sessionId, BdfDictionary d)
+			throws FormatException {
+		MessageId lastLocalMessageId =
+				getMessageId(d, SESSION_KEY_LAST_LOCAL_MESSAGE_ID);
+		MessageId lastRemoteMessageId =
+				getMessageId(d, SESSION_KEY_LAST_REMOTE_MESSAGE_ID);
+		long localTimestamp = d.getLong(SESSION_KEY_LOCAL_TIMESTAMP);
+		GroupId groupId = getGroupId(d, SESSION_KEY_GROUP_ID);
+		Author author = getAuthor(d, SESSION_KEY_AUTHOR);
+		return new Introducee(sessionId, groupId, author, localTimestamp,
+				lastLocalMessageId, lastRemoteMessageId);
+	}
+
+	@Override
+	public IntroduceeSession parseIntroduceeSession(GroupId introducerGroupId,
+			BdfDictionary d) throws FormatException {
+		if (getRole(d) != INTRODUCEE) throw new IllegalArgumentException();
+		SessionId sessionId = getSessionId(d);
+		IntroduceeState state = IntroduceeState.fromValue(getState(d));
+		long requestTimestamp = d.getLong(SESSION_KEY_REQUEST_TIMESTAMP);
+		Author introducer = getAuthor(d, SESSION_KEY_INTRODUCER);
+		Local local = parseLocal(d.getDictionary(SESSION_KEY_LOCAL));
+		Remote remote = parseRemote(d.getDictionary(SESSION_KEY_REMOTE));
+		byte[] masterKey = d.getOptionalRaw(SESSION_KEY_MASTER_KEY);
+		Map<TransportId, KeySetId> transportKeys = parseTransportKeys(
+				d.getOptionalDictionary(SESSION_KEY_TRANSPORT_KEYS));
+		return new IntroduceeSession(sessionId, state, requestTimestamp,
+				introducerGroupId, introducer, local, remote,
+				masterKey, transportKeys);
+	}
+
+	private Local parseLocal(BdfDictionary d) throws FormatException {
+		boolean alice = d.getBoolean(SESSION_KEY_ALICE);
+		MessageId lastLocalMessageId =
+				getMessageId(d, SESSION_KEY_LAST_LOCAL_MESSAGE_ID);
+		long localTimestamp = d.getLong(SESSION_KEY_LOCAL_TIMESTAMP);
+		byte[] ephemeralPublicKey =
+				d.getOptionalRaw(SESSION_KEY_EPHEMERAL_PUBLIC_KEY);
+		BdfDictionary tpDict =
+				d.getOptionalDictionary(SESSION_KEY_TRANSPORT_PROPERTIES);
+		byte[] ephemeralPrivateKey =
+				d.getOptionalRaw(SESSION_KEY_EPHEMERAL_PRIVATE_KEY);
+		Map<TransportId, TransportProperties> transportProperties =
+				tpDict == null ? null : clientHelper
+						.parseAndValidateTransportPropertiesMap(tpDict);
+		long acceptTimestamp = d.getLong(SESSION_KEY_ACCEPT_TIMESTAMP);
+		byte[] macKey = d.getOptionalRaw(SESSION_KEY_MAC_KEY);
+		return new Local(alice, lastLocalMessageId, localTimestamp,
+				ephemeralPublicKey, ephemeralPrivateKey, transportProperties,
+				acceptTimestamp, macKey);
+	}
+
+	private Remote parseRemote(BdfDictionary d) throws FormatException {
+		boolean alice = d.getBoolean(SESSION_KEY_ALICE);
+		Author remoteAuthor = getAuthor(d, SESSION_KEY_REMOTE_AUTHOR);
+		MessageId lastRemoteMessageId =
+				getMessageId(d, SESSION_KEY_LAST_REMOTE_MESSAGE_ID);
+		byte[] ephemeralPublicKey =
+				d.getOptionalRaw(SESSION_KEY_EPHEMERAL_PUBLIC_KEY);
+		BdfDictionary tpDict =
+				d.getOptionalDictionary(SESSION_KEY_TRANSPORT_PROPERTIES);
+		Map<TransportId, TransportProperties> transportProperties =
+				tpDict == null ? null : clientHelper
+						.parseAndValidateTransportPropertiesMap(tpDict);
+		long acceptTimestamp = d.getLong(SESSION_KEY_ACCEPT_TIMESTAMP);
+		byte[] macKey = d.getOptionalRaw(SESSION_KEY_MAC_KEY);
+		return new Remote(alice, remoteAuthor, lastRemoteMessageId,
+				ephemeralPublicKey, transportProperties, acceptTimestamp,
+				macKey);
+	}
+
+	private int getState(BdfDictionary d) throws FormatException {
+		return d.getLong(SESSION_KEY_STATE).intValue();
+	}
+
+	private SessionId getSessionId(BdfDictionary d) throws FormatException {
+		byte[] b = d.getRaw(SESSION_KEY_SESSION_ID);
+		return new SessionId(b);
+	}
+
+	@Nullable
+	private MessageId getMessageId(BdfDictionary d, String key)
+			throws FormatException {
+		byte[] b = d.getOptionalRaw(key);
+		return b == null ? null : new MessageId(b);
+	}
+
+	private GroupId getGroupId(BdfDictionary d, String key)
+			throws FormatException {
+		return new GroupId(d.getRaw(key));
+	}
+
+	private Author getAuthor(BdfDictionary d, String key)
+			throws FormatException {
+		return clientHelper.parseAndValidateAuthor(d.getList(key));
+	}
+
+	@Nullable
+	private Map<TransportId, KeySetId> parseTransportKeys(
+			@Nullable BdfDictionary d) throws FormatException {
+		if (d == null) return null;
+		Map<TransportId, KeySetId> map = new HashMap<>(d.size());
+		for (String key : d.keySet()) {
+			map.put(new TransportId(key),
+					new KeySetId(d.getLong(key).intValue())
+			);
+		}
+		return map;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction/State.java b/briar-core/src/main/java/org/briarproject/briar/introduction/State.java
new file mode 100644
index 0000000000000000000000000000000000000000..3063f9bd83d1761fc6c4b5446a482b00f0329f80
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction/State.java
@@ -0,0 +1,7 @@
+package org.briarproject.briar.introduction;
+
+interface State {
+
+	int getValue();
+
+}
diff --git a/briar-core/src/test/java/org/briarproject/briar/client/MessageQueueManagerImplTest.java b/briar-core/src/test/java/org/briarproject/briar/client/MessageQueueManagerImplTest.java
deleted file mode 100644
index 0a11b0fea447cbb09a53982ae98db25d70c047ba..0000000000000000000000000000000000000000
--- a/briar-core/src/test/java/org/briarproject/briar/client/MessageQueueManagerImplTest.java
+++ /dev/null
@@ -1,566 +0,0 @@
-package org.briarproject.briar.client;
-
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.Metadata;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.sync.ClientId;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.InvalidMessageException;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageContext;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.sync.ValidationManager;
-import org.briarproject.bramble.api.sync.ValidationManager.IncomingMessageHook;
-import org.briarproject.bramble.api.sync.ValidationManager.MessageValidator;
-import org.briarproject.bramble.test.CaptureArgumentAction;
-import org.briarproject.bramble.test.TestUtils;
-import org.briarproject.bramble.util.ByteUtils;
-import org.briarproject.briar.api.client.MessageQueueManager.IncomingQueueMessageHook;
-import org.briarproject.briar.api.client.MessageQueueManager.QueueMessageValidator;
-import org.briarproject.briar.api.client.QueueMessage;
-import org.briarproject.briar.api.client.QueueMessageFactory;
-import org.briarproject.briar.test.BriarTestCase;
-import org.hamcrest.Description;
-import org.jmock.Expectations;
-import org.jmock.Mockery;
-import org.jmock.api.Action;
-import org.jmock.api.Invocation;
-import org.junit.Test;
-
-import java.util.concurrent.atomic.AtomicReference;
-
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-import static org.briarproject.bramble.test.TestUtils.getClientId;
-import static org.briarproject.bramble.test.TestUtils.getGroup;
-import static org.briarproject.briar.api.client.MessageQueueManager.QUEUE_STATE_KEY;
-import static org.briarproject.briar.api.client.QueueMessage.QUEUE_MESSAGE_HEADER_LENGTH;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.fail;
-
-public class MessageQueueManagerImplTest extends BriarTestCase {
-
-	private final ClientId clientId = getClientId();
-	private final Group group = getGroup(clientId);
-	private final GroupId groupId = group.getId();
-	private final long timestamp = System.currentTimeMillis();
-
-	@Test
-	public void testSendingMessages() throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-
-		Transaction txn = new Transaction(null, false);
-		byte[] body = new byte[123];
-		Metadata groupMetadata = new Metadata();
-		Metadata messageMetadata = new Metadata();
-		Metadata groupMetadata1 = new Metadata();
-		byte[] queueState = new byte[123];
-		groupMetadata1.put(QUEUE_STATE_KEY, queueState);
-
-		context.checking(new Expectations() {{
-			// First message: queue state does not exist
-			oneOf(db).getGroupMetadata(txn, groupId);
-			will(returnValue(groupMetadata));
-			oneOf(clientHelper).toByteArray(with(any(BdfDictionary.class)));
-			will(new EncodeQueueStateAction(1L, 0L, new BdfList()));
-			oneOf(db).mergeGroupMetadata(with(txn), with(groupId),
-					with(any(Metadata.class)));
-			oneOf(queueMessageFactory).createMessage(groupId, timestamp, 0L,
-					body);
-			will(new CreateMessageAction());
-			oneOf(db).addLocalMessage(with(txn), with(any(QueueMessage.class)),
-					with(messageMetadata), with(true));
-			// Second message: queue state exists
-			oneOf(db).getGroupMetadata(txn, groupId);
-			will(returnValue(groupMetadata1));
-			oneOf(clientHelper).toDictionary(queueState, 0, queueState.length);
-			will(new DecodeQueueStateAction(1L, 0L, new BdfList()));
-			oneOf(clientHelper).toByteArray(with(any(BdfDictionary.class)));
-			will(new EncodeQueueStateAction(2L, 0L, new BdfList()));
-			oneOf(db).mergeGroupMetadata(with(txn), with(groupId),
-					with(any(Metadata.class)));
-			oneOf(queueMessageFactory).createMessage(groupId, timestamp, 1L,
-					body);
-			will(new CreateMessageAction());
-			oneOf(db).addLocalMessage(with(txn), with(any(QueueMessage.class)),
-					with(messageMetadata), with(true));
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// First message
-		QueueMessage q = mqm.sendMessage(txn, group, timestamp, body,
-				messageMetadata);
-		assertEquals(groupId, q.getGroupId());
-		assertEquals(timestamp, q.getTimestamp());
-		assertEquals(0L, q.getQueuePosition());
-		assertEquals(QUEUE_MESSAGE_HEADER_LENGTH + body.length, q.getLength());
-
-		// Second message
-		QueueMessage q1 = mqm.sendMessage(txn, group, timestamp, body,
-				messageMetadata);
-		assertEquals(groupId, q1.getGroupId());
-		assertEquals(timestamp, q1.getTimestamp());
-		assertEquals(1L, q1.getQueuePosition());
-		assertEquals(QUEUE_MESSAGE_HEADER_LENGTH + body.length, q1.getLength());
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testValidatorRejectsShortMessage() throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-
-		AtomicReference<MessageValidator> captured = new AtomicReference<>();
-		QueueMessageValidator queueMessageValidator =
-				context.mock(QueueMessageValidator.class);
-		// The message is too short to be a valid queue message
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH - 1];
-		Message message = new Message(messageId, groupId, timestamp, raw);
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerMessageValidator(with(clientId),
-					with(any(MessageValidator.class)));
-			will(new CaptureArgumentAction<>(captured,
-					MessageValidator.class, 1));
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating message validator
-		mqm.registerMessageValidator(clientId, queueMessageValidator);
-		MessageValidator delegate = captured.get();
-		assertNotNull(delegate);
-		// The message should be invalid
-		try {
-			delegate.validateMessage(message, group);
-			fail();
-		} catch (InvalidMessageException expected) {
-			// Expected
-		}
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testValidatorRejectsNegativeQueuePosition() throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-
-		AtomicReference<MessageValidator> captured = new AtomicReference<>();
-		QueueMessageValidator queueMessageValidator =
-				context.mock(QueueMessageValidator.class);
-		// The message has a negative queue position
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		for (int i = 0; i < 8; i++)
-			raw[MESSAGE_HEADER_LENGTH + i] = (byte) 0xFF;
-		Message message = new Message(messageId, groupId, timestamp, raw);
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerMessageValidator(with(clientId),
-					with(any(MessageValidator.class)));
-			will(new CaptureArgumentAction<>(captured,
-					MessageValidator.class, 1));
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating message validator
-		mqm.registerMessageValidator(clientId, queueMessageValidator);
-		MessageValidator delegate = captured.get();
-		assertNotNull(delegate);
-		// The message should be invalid
-		try {
-			delegate.validateMessage(message, group);
-			fail();
-		} catch (InvalidMessageException expected) {
-			// Expected
-		}
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testValidatorDelegatesValidMessage() throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-
-		AtomicReference<MessageValidator> captured = new AtomicReference<>();
-		QueueMessageValidator queueMessageValidator =
-				context.mock(QueueMessageValidator.class);
-		Metadata metadata = new Metadata();
-		MessageContext messageContext =
-				new MessageContext(metadata);
-		// The message is valid, with a queue position of zero
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		Message message = new Message(messageId, groupId, timestamp, raw);
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerMessageValidator(with(clientId),
-					with(any(MessageValidator.class)));
-			will(new CaptureArgumentAction<>(captured,
-					MessageValidator.class, 1));
-			// The message should be delegated
-			oneOf(queueMessageValidator).validateMessage(
-					with(any(QueueMessage.class)), with(group));
-			will(returnValue(messageContext));
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating message validator
-		mqm.registerMessageValidator(clientId, queueMessageValidator);
-		MessageValidator delegate = captured.get();
-		assertNotNull(delegate);
-		// The message should be valid and the metadata should be returned
-		assertSame(messageContext, delegate.validateMessage(message, group));
-		assertSame(metadata, messageContext.getMetadata());
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testIncomingMessageHookDeletesDuplicateMessage()
-			throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-		AtomicReference<IncomingMessageHook> captured = new AtomicReference<>();
-		IncomingQueueMessageHook incomingQueueMessageHook =
-				context.mock(IncomingQueueMessageHook.class);
-
-		Transaction txn = new Transaction(null, false);
-		Metadata groupMetadata = new Metadata();
-		byte[] queueState = new byte[123];
-		groupMetadata.put(QUEUE_STATE_KEY, queueState);
-		// The message has queue position 0
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		Message message = new Message(messageId, groupId, timestamp, raw);
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerIncomingMessageHook(with(clientId),
-					with(any(IncomingMessageHook.class)));
-			will(new CaptureArgumentAction<>(captured,
-					IncomingMessageHook.class, 1));
-			oneOf(db).getGroupMetadata(txn, groupId);
-			will(returnValue(groupMetadata));
-			// Queue position 1 is expected
-			oneOf(clientHelper).toDictionary(queueState, 0, queueState.length);
-			will(new DecodeQueueStateAction(0L, 1L, new BdfList()));
-			// The message and its metadata should be deleted
-			oneOf(db).deleteMessage(txn, messageId);
-			oneOf(db).deleteMessageMetadata(txn, messageId);
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating incoming message hook
-		mqm.registerIncomingMessageHook(clientId, incomingQueueMessageHook);
-		IncomingMessageHook delegate = captured.get();
-		assertNotNull(delegate);
-		// Pass the message to the hook
-		delegate.incomingMessage(txn, message, new Metadata());
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testIncomingMessageHookAddsOutOfOrderMessageToPendingList()
-			throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-		AtomicReference<IncomingMessageHook> captured = new AtomicReference<>();
-		IncomingQueueMessageHook incomingQueueMessageHook =
-				context.mock(IncomingQueueMessageHook.class);
-
-		Transaction txn = new Transaction(null, false);
-		Metadata groupMetadata = new Metadata();
-		byte[] queueState = new byte[123];
-		groupMetadata.put(QUEUE_STATE_KEY, queueState);
-		// The message has queue position 1
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		ByteUtils.writeUint64(1L, raw, MESSAGE_HEADER_LENGTH);
-		Message message = new Message(messageId, groupId, timestamp, raw);
-		BdfList pending = BdfList.of(BdfList.of(1L, messageId));
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerIncomingMessageHook(with(clientId),
-					with(any(IncomingMessageHook.class)));
-			will(new CaptureArgumentAction<>(captured,
-					IncomingMessageHook.class, 1));
-			oneOf(db).getGroupMetadata(txn, groupId);
-			will(returnValue(groupMetadata));
-			// Queue position 0 is expected
-			oneOf(clientHelper).toDictionary(queueState, 0, queueState.length);
-			will(new DecodeQueueStateAction(0L, 0L, new BdfList()));
-			// The message should be added to the pending list
-			oneOf(clientHelper).toByteArray(with(any(BdfDictionary.class)));
-			will(new EncodeQueueStateAction(0L, 0L, pending));
-			oneOf(db).mergeGroupMetadata(with(txn), with(groupId),
-					with(any(Metadata.class)));
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating incoming message hook
-		mqm.registerIncomingMessageHook(clientId, incomingQueueMessageHook);
-		IncomingMessageHook delegate = captured.get();
-		assertNotNull(delegate);
-		// Pass the message to the hook
-		delegate.incomingMessage(txn, message, new Metadata());
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testIncomingMessageHookDelegatesInOrderMessage()
-			throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-		AtomicReference<IncomingMessageHook> captured = new AtomicReference<>();
-		IncomingQueueMessageHook incomingQueueMessageHook =
-				context.mock(IncomingQueueMessageHook.class);
-
-		Transaction txn = new Transaction(null, false);
-		Metadata groupMetadata = new Metadata();
-		byte[] queueState = new byte[123];
-		groupMetadata.put(QUEUE_STATE_KEY, queueState);
-		// The message has queue position 0
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		Message message = new Message(messageId, groupId, timestamp, raw);
-		Metadata messageMetadata = new Metadata();
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerIncomingMessageHook(with(clientId),
-					with(any(IncomingMessageHook.class)));
-			will(new CaptureArgumentAction<>(captured,
-					IncomingMessageHook.class, 1));
-			oneOf(db).getGroupMetadata(txn, groupId);
-			will(returnValue(groupMetadata));
-			// Queue position 0 is expected
-			oneOf(clientHelper).toDictionary(queueState, 0, queueState.length);
-			will(new DecodeQueueStateAction(0L, 0L, new BdfList()));
-			// Queue position 1 should be expected next
-			oneOf(clientHelper).toByteArray(with(any(BdfDictionary.class)));
-			will(new EncodeQueueStateAction(0L, 1L, new BdfList()));
-			oneOf(db).mergeGroupMetadata(with(txn), with(groupId),
-					with(any(Metadata.class)));
-			// The message should be delegated
-			oneOf(incomingQueueMessageHook).incomingMessage(with(txn),
-					with(any(QueueMessage.class)), with(messageMetadata));
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating incoming message hook
-		mqm.registerIncomingMessageHook(clientId, incomingQueueMessageHook);
-		IncomingMessageHook delegate = captured.get();
-		assertNotNull(delegate);
-		// Pass the message to the hook
-		delegate.incomingMessage(txn, message, messageMetadata);
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testIncomingMessageHookRetrievesPendingMessage()
-			throws Exception {
-		Mockery context = new Mockery();
-		DatabaseComponent db = context.mock(DatabaseComponent.class);
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		QueueMessageFactory queueMessageFactory =
-				context.mock(QueueMessageFactory.class);
-		ValidationManager validationManager =
-				context.mock(ValidationManager.class);
-		AtomicReference<IncomingMessageHook> captured = new AtomicReference<>();
-		IncomingQueueMessageHook incomingQueueMessageHook =
-				context.mock(IncomingQueueMessageHook.class);
-
-		Transaction txn = new Transaction(null, false);
-		Metadata groupMetadata = new Metadata();
-		byte[] queueState = new byte[123];
-		groupMetadata.put(QUEUE_STATE_KEY, queueState);
-		// The message has queue position 0
-		MessageId messageId = new MessageId(TestUtils.getRandomId());
-		byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		Message message = new Message(messageId, groupId, timestamp, raw);
-		Metadata messageMetadata = new Metadata();
-		// Queue position 1 is pending
-		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
-		byte[] raw1 = new byte[QUEUE_MESSAGE_HEADER_LENGTH];
-		QueueMessage message1 = new QueueMessage(messageId1, groupId,
-				timestamp, 1L, raw1);
-		Metadata messageMetadata1 = new Metadata();
-		BdfList pending = BdfList.of(BdfList.of(1L, messageId1));
-
-		context.checking(new Expectations() {{
-			oneOf(validationManager).registerIncomingMessageHook(with(clientId),
-					with(any(IncomingMessageHook.class)));
-			will(new CaptureArgumentAction<>(captured,
-					IncomingMessageHook.class, 1));
-			oneOf(db).getGroupMetadata(txn, groupId);
-			will(returnValue(groupMetadata));
-			// Queue position 0 is expected, position 1 is pending
-			oneOf(clientHelper).toDictionary(queueState, 0, queueState.length);
-			will(new DecodeQueueStateAction(0L, 0L, pending));
-			// Queue position 2 should be expected next
-			oneOf(clientHelper).toByteArray(with(any(BdfDictionary.class)));
-			will(new EncodeQueueStateAction(0L, 2L, new BdfList()));
-			oneOf(db).mergeGroupMetadata(with(txn), with(groupId),
-					with(any(Metadata.class)));
-			// The new message should be delegated
-			oneOf(incomingQueueMessageHook).incomingMessage(with(txn),
-					with(any(QueueMessage.class)), with(messageMetadata));
-			// The pending message should be retrieved
-			oneOf(db).getRawMessage(txn, messageId1);
-			will(returnValue(raw1));
-			oneOf(db).getMessageMetadata(txn, messageId1);
-			will(returnValue(messageMetadata1));
-			oneOf(queueMessageFactory).createMessage(messageId1, raw1);
-			will(returnValue(message1));
-			// The pending message should be delegated
-			oneOf(incomingQueueMessageHook).incomingMessage(txn, message1,
-					messageMetadata1);
-		}});
-
-		MessageQueueManagerImpl mqm = new MessageQueueManagerImpl(db,
-				clientHelper, queueMessageFactory, validationManager);
-
-		// Capture the delegating incoming message hook
-		mqm.registerIncomingMessageHook(clientId, incomingQueueMessageHook);
-		IncomingMessageHook delegate = captured.get();
-		assertNotNull(delegate);
-		// Pass the message to the hook
-		delegate.incomingMessage(txn, message, messageMetadata);
-
-		context.assertIsSatisfied();
-	}
-
-	private class EncodeQueueStateAction implements Action {
-
-		private final long outgoingPosition, incomingPosition;
-		private final BdfList pending;
-
-		private EncodeQueueStateAction(long outgoingPosition,
-				long incomingPosition, BdfList pending) {
-			this.outgoingPosition = outgoingPosition;
-			this.incomingPosition = incomingPosition;
-			this.pending = pending;
-		}
-
-		@Override
-		public Object invoke(Invocation invocation) throws Throwable {
-			BdfDictionary d = (BdfDictionary) invocation.getParameter(0);
-			assertEquals(outgoingPosition, d.getLong("nextOut").longValue());
-			assertEquals(incomingPosition, d.getLong("nextIn").longValue());
-			assertEquals(pending, d.getList("pending"));
-			return new byte[123];
-		}
-
-		@Override
-		public void describeTo(Description description) {
-			description.appendText("encodes a queue state");
-		}
-	}
-
-	private class DecodeQueueStateAction implements Action {
-
-		private final long outgoingPosition, incomingPosition;
-		private final BdfList pending;
-
-		private DecodeQueueStateAction(long outgoingPosition,
-				long incomingPosition, BdfList pending) {
-			this.outgoingPosition = outgoingPosition;
-			this.incomingPosition = incomingPosition;
-			this.pending = pending;
-		}
-
-		@Override
-		public Object invoke(Invocation invocation) throws Throwable {
-			BdfDictionary d = new BdfDictionary();
-			d.put("nextOut", outgoingPosition);
-			d.put("nextIn", incomingPosition);
-			d.put("pending", pending);
-			return d;
-		}
-
-		@Override
-		public void describeTo(Description description) {
-			description.appendText("decodes a queue state");
-		}
-	}
-
-	private class CreateMessageAction implements Action {
-
-		@Override
-		public Object invoke(Invocation invocation) throws Throwable {
-			GroupId groupId = (GroupId) invocation.getParameter(0);
-			long timestamp = (Long) invocation.getParameter(1);
-			long queuePosition = (Long) invocation.getParameter(2);
-			byte[] body = (byte[]) invocation.getParameter(3);
-			byte[] raw = new byte[QUEUE_MESSAGE_HEADER_LENGTH + body.length];
-			MessageId id = new MessageId(TestUtils.getRandomId());
-			return new QueueMessage(id, groupId, timestamp, queuePosition, raw);
-		}
-
-		@Override
-		public void describeTo(Description description) {
-			description.appendText("creates a message");
-		}
-	}
-
-}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroduceeManagerTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroduceeManagerTest.java
deleted file mode 100644
index 5f8391d9b8be793e3c53290c1b6ad3be1d18e3b5..0000000000000000000000000000000000000000
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroduceeManagerTest.java
+++ /dev/null
@@ -1,424 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.Bytes;
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.contact.Contact;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.contact.ContactManager;
-import org.briarproject.bramble.api.crypto.CryptoComponent;
-import org.briarproject.bramble.api.crypto.SecretKey;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfEntry;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.identity.Author;
-import org.briarproject.bramble.api.identity.AuthorFactory;
-import org.briarproject.bramble.api.identity.AuthorId;
-import org.briarproject.bramble.api.identity.IdentityManager;
-import org.briarproject.bramble.api.properties.TransportPropertyManager;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.api.introduction.IntroduceeProtocolState;
-import org.briarproject.briar.test.BriarTestCase;
-import org.jmock.Expectations;
-import org.jmock.Mockery;
-import org.jmock.lib.legacy.ClassImposteriser;
-import org.junit.Test;
-
-import java.security.GeneralSecurityException;
-import java.security.SecureRandom;
-
-import static org.briarproject.bramble.api.crypto.CryptoConstants.MAX_AGREEMENT_PUBLIC_KEY_BYTES;
-import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-import static org.briarproject.bramble.test.TestUtils.getAuthor;
-import static org.briarproject.bramble.test.TestUtils.getGroup;
-import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
-import static org.briarproject.bramble.test.TestUtils.getRandomId;
-import static org.briarproject.bramble.test.TestUtils.getSecretKey;
-import static org.briarproject.briar.api.introduction.IntroduceeProtocolState.AWAIT_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ADDED_CONTACT_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ANSWERED;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.EXISTS;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.LOCAL_AUTHOR_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_LENGTH;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NONCE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NOT_OUR_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.REMOTE_AUTHOR_IS_US;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCEE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNING_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STORAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
-import static org.hamcrest.Matchers.array;
-import static org.hamcrest.Matchers.samePropertyValuesAs;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-
-public class IntroduceeManagerTest extends BriarTestCase {
-
-	private final Mockery context;
-	private final IntroduceeManager introduceeManager;
-	private final DatabaseComponent db;
-	private final CryptoComponent cryptoComponent;
-	private final ClientHelper clientHelper;
-	private final IntroductionGroupFactory introductionGroupFactory;
-	private final AuthorFactory authorFactory;
-	private final ContactManager contactManager;
-	private final Clock clock;
-	private final Contact introducer;
-	private final Contact introducee1;
-	private final Contact introducee2;
-	private final Group localGroup1;
-	private final Group introductionGroup1;
-	private final Transaction txn;
-	private final long time = 42L;
-	private final Message localStateMessage;
-	private final SessionId sessionId;
-	private final Message message1;
-
-	public IntroduceeManagerTest() {
-		context = new Mockery();
-		context.setImposteriser(ClassImposteriser.INSTANCE);
-		MessageSender messageSender = context.mock(MessageSender.class);
-		db = context.mock(DatabaseComponent.class);
-		cryptoComponent = context.mock(CryptoComponent.class);
-		clientHelper = context.mock(ClientHelper.class);
-		clock = context.mock(Clock.class);
-		introductionGroupFactory =
-				context.mock(IntroductionGroupFactory.class);
-		TransportPropertyManager transportPropertyManager =
-				context.mock(TransportPropertyManager.class);
-		authorFactory = context.mock(AuthorFactory.class);
-		contactManager = context.mock(ContactManager.class);
-		IdentityManager identityManager = context.mock(IdentityManager.class);
-
-		introduceeManager = new IntroduceeManager(messageSender, db,
-				clientHelper, clock, cryptoComponent, transportPropertyManager,
-				authorFactory, contactManager, identityManager,
-				introductionGroupFactory);
-
-		Author author0 = getAuthor();
-		AuthorId localAuthorId = new AuthorId(getRandomId());
-		ContactId contactId0 = new ContactId(234);
-		introducer =
-				new Contact(contactId0, author0, localAuthorId, true, true);
-
-		Author author1 = getAuthor();
-		AuthorId localAuthorId1 = new AuthorId(getRandomId());
-		ContactId contactId1 = new ContactId(234);
-		introducee1 =
-				new Contact(contactId1, author1, localAuthorId1, true, true);
-
-		Author author2 = getAuthor();
-		ContactId contactId2 = new ContactId(235);
-		introducee2 =
-				new Contact(contactId2, author2, localAuthorId, true, true);
-
-		localGroup1 = getGroup(CLIENT_ID);
-		introductionGroup1 = getGroup(CLIENT_ID);
-
-		sessionId = new SessionId(getRandomId());
-		localStateMessage = new Message(
-				new MessageId(getRandomId()),
-				localGroup1.getId(),
-				time,
-				getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
-		);
-		message1 = new Message(
-				new MessageId(getRandomId()),
-				introductionGroup1.getId(),
-				time,
-				getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
-		);
-
-		txn = new Transaction(null, false);
-	}
-
-	@Test
-	public void testIncomingRequestMessage()
-			throws DbException, FormatException {
-
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_REQUEST);
-		msg.put(GROUP_ID, introductionGroup1.getId());
-		msg.put(SESSION_ID, sessionId);
-		msg.put(MESSAGE_ID, message1.getId());
-		msg.put(MESSAGE_TIME, time);
-		msg.put(NAME, introducee2.getAuthor().getName());
-		msg.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
-
-		BdfDictionary state =
-				initializeSessionState(txn, introductionGroup1.getId(), msg);
-
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).mergeMessageMetadata(txn,
-					localStateMessage.getId(), state);
-		}});
-
-		introduceeManager.incomingMessage(txn, state, msg);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-	@Test
-	public void testIncomingResponseMessage()
-			throws DbException, FormatException {
-
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_RESPONSE);
-		msg.put(GROUP_ID, introductionGroup1.getId());
-		msg.put(SESSION_ID, sessionId);
-		msg.put(MESSAGE_ID, message1.getId());
-		msg.put(MESSAGE_TIME, time);
-		msg.put(NAME, introducee2.getAuthor().getName());
-		msg.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
-
-		BdfDictionary state =
-				initializeSessionState(txn, introductionGroup1.getId(), msg);
-		state.put(STATE, IntroduceeProtocolState.AWAIT_RESPONSES.ordinal());
-
-		// turn request message into a response
-		msg.put(ACCEPT, true);
-		msg.put(TIME, time);
-		msg.put(E_PUBLIC_KEY, getRandomBytes(MAX_AGREEMENT_PUBLIC_KEY_BYTES));
-		msg.put(TRANSPORT, new BdfDictionary());
-
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).mergeMessageMetadata(txn,
-					localStateMessage.getId(), state);
-		}});
-
-		introduceeManager.incomingMessage(txn, state, msg);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-	@Test
-	public void testDetectReplacedEphemeralPublicKey()
-			throws DbException, FormatException, GeneralSecurityException {
-
-		// TODO MR !237 should use its new default initialization method here
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_RESPONSE);
-		msg.put(GROUP_ID, introductionGroup1.getId());
-		msg.put(SESSION_ID, sessionId);
-		msg.put(MESSAGE_ID, message1.getId());
-		msg.put(MESSAGE_TIME, time);
-		msg.put(NAME, introducee2.getAuthor().getName());
-		msg.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
-		BdfDictionary state =
-				initializeSessionState(txn, introductionGroup1.getId(), msg);
-
-		// prepare state for incoming ACK
-		state.put(STATE, IntroduceeProtocolState.AWAIT_ACK.ordinal());
-		state.put(ADDED_CONTACT_ID, 2);
-		byte[] nonce = getRandomBytes(42);
-		state.put(NONCE, nonce);
-		state.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
-
-		// create incoming ACK message
-		byte[] mac = getRandomBytes(MAC_LENGTH);
-		byte[] sig = getRandomBytes(MAX_SIGNATURE_LENGTH);
-		BdfDictionary ack = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_ACK),
-				new BdfEntry(SESSION_ID, sessionId),
-				new BdfEntry(GROUP_ID, introductionGroup1.getId()),
-				new BdfEntry(MAC, mac),
-				new BdfEntry(SIGNATURE, sig)
-		);
-
-		context.checking(new Expectations() {{
-			oneOf(cryptoComponent).verifySignature(sig, SIGNING_LABEL, nonce,
-					introducee2.getAuthor().getPublicKey());
-			will(returnValue(false));
-		}});
-
-		try {
-			introduceeManager.incomingMessage(txn, state, ack);
-			fail();
-		} catch (DbException e) {
-			// expected
-			assertTrue(e.getCause() instanceof GeneralSecurityException);
-		}
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-	@Test
-	public void testSignatureVerification()
-			throws FormatException, DbException, GeneralSecurityException {
-
-		byte[] publicKeyBytes = introducee2.getAuthor().getPublicKey();
-		byte[] nonce = getRandomBytes(MAC_LENGTH);
-		byte[] sig = getRandomBytes(MAC_LENGTH);
-
-		BdfDictionary state = new BdfDictionary();
-		state.put(PUBLIC_KEY, publicKeyBytes);
-		state.put(NONCE, nonce);
-		state.put(SIGNATURE, sig);
-
-		context.checking(new Expectations() {{
-			oneOf(cryptoComponent).verifySignature(sig, SIGNING_LABEL, nonce,
-					publicKeyBytes);
-			will(returnValue(true));
-		}});
-		introduceeManager.verifySignature(state);
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testMacVerification()
-			throws FormatException, DbException, GeneralSecurityException {
-
-		byte[] publicKeyBytes = introducee2.getAuthor().getPublicKey();
-		BdfDictionary tp = BdfDictionary.of(new BdfEntry("fake", "fake"));
-		byte[] ePublicKeyBytes = getRandomBytes(MAX_AGREEMENT_PUBLIC_KEY_BYTES);
-		byte[] mac = getRandomBytes(MAC_LENGTH);
-		SecretKey macKey = getSecretKey();
-
-		// move state to where it would be after an ACK arrived
-		BdfDictionary state = new BdfDictionary();
-		state.put(PUBLIC_KEY, publicKeyBytes);
-		state.put(TRANSPORT, tp);
-		state.put(TIME, time);
-		state.put(E_PUBLIC_KEY, ePublicKeyBytes);
-		state.put(MAC, mac);
-		state.put(MAC_KEY, macKey.getBytes());
-
-		byte[] signBytes = getRandomBytes(42);
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).toByteArray(
-					BdfList.of(publicKeyBytes, ePublicKeyBytes, tp, time));
-			will(returnValue(signBytes));
-			//noinspection unchecked
-			oneOf(cryptoComponent).mac(with(MAC_LABEL),
-					with(samePropertyValuesAs(macKey)),
-					with(array(equal(signBytes))));
-			will(returnValue(mac));
-		}});
-		introduceeManager.verifyMac(state);
-		context.assertIsSatisfied();
-
-		// now produce wrong MAC
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).toByteArray(
-					BdfList.of(publicKeyBytes, ePublicKeyBytes, tp, time));
-			will(returnValue(signBytes));
-			//noinspection unchecked
-			oneOf(cryptoComponent).mac(with(MAC_LABEL),
-					with(samePropertyValuesAs(macKey)),
-					with(array(equal(signBytes))));
-			will(returnValue(getRandomBytes(MAC_LENGTH)));
-		}});
-		try {
-			introduceeManager.verifyMac(state);
-			fail();
-		} catch (GeneralSecurityException e) {
-			// expected
-		}
-		context.assertIsSatisfied();
-	}
-
-	private BdfDictionary initializeSessionState(Transaction txn,
-			GroupId groupId, BdfDictionary msg)
-			throws DbException, FormatException {
-
-		SecureRandom secureRandom = context.mock(SecureRandom.class);
-		Bytes salt = new Bytes(new byte[64]);
-		BdfDictionary groupMetadata = BdfDictionary.of(
-				new BdfEntry(CONTACT, introducee1.getId().getInt())
-		);
-		boolean contactExists = false;
-		BdfDictionary state = new BdfDictionary();
-		state.put(STORAGE_ID, localStateMessage.getId());
-		state.put(STATE, AWAIT_REQUEST.getValue());
-		state.put(ROLE, ROLE_INTRODUCEE);
-		state.put(GROUP_ID, groupId);
-		state.put(INTRODUCER, introducer.getAuthor().getName());
-		state.put(CONTACT_ID_1, introducer.getId().getInt());
-		state.put(LOCAL_AUTHOR_ID, introducer.getLocalAuthorId().getBytes());
-		state.put(NOT_OUR_RESPONSE, localStateMessage.getId());
-		state.put(ANSWERED, false);
-		state.put(EXISTS, contactExists);
-		state.put(REMOTE_AUTHOR_ID, introducee2.getAuthor().getId());
-		state.put(REMOTE_AUTHOR_IS_US, false);
-
-		context.checking(new Expectations() {{
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(time));
-			oneOf(cryptoComponent).getSecureRandom();
-			will(returnValue(secureRandom));
-			oneOf(secureRandom).nextBytes(salt.getBytes());
-			oneOf(introductionGroupFactory).createLocalGroup();
-			will(returnValue(localGroup1));
-			oneOf(clientHelper)
-					.createMessage(localGroup1.getId(), time, BdfList.of(salt));
-			will(returnValue(localStateMessage));
-
-			// who is making the introduction? who is the introducer?
-			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
-					groupId);
-			will(returnValue(groupMetadata));
-			oneOf(db).getContact(txn, introducer.getId());
-			will(returnValue(introducer));
-
-			// create remote author to check if contact exists
-			oneOf(authorFactory).createAuthor(introducee2.getAuthor().getName(),
-					introducee2.getAuthor().getPublicKey());
-			will(returnValue(introducee2.getAuthor()));
-			oneOf(contactManager)
-					.contactExists(txn, introducee2.getAuthor().getId(),
-							introducer.getLocalAuthorId());
-			will(returnValue(contactExists));
-
-			// checks if remote author is one of our identities
-			oneOf(db).containsLocalAuthor(txn, introducee2.getAuthor().getId());
-			will(returnValue(false));
-
-			// store session state
-			oneOf(clientHelper)
-					.addLocalMessage(txn, localStateMessage, state, false);
-		}});
-
-		BdfDictionary result = introduceeManager.initialize(txn, groupId, msg);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-		return result;
-	}
-
-}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroducerManagerTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroducerManagerTest.java
deleted file mode 100644
index 558b26ec9b2fdb9452676dc54e4b86ff8bf2418c..0000000000000000000000000000000000000000
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroducerManagerTest.java
+++ /dev/null
@@ -1,179 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.Bytes;
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.contact.Contact;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.crypto.CryptoComponent;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.identity.Author;
-import org.briarproject.bramble.api.identity.AuthorId;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.test.BriarTestCase;
-import org.jmock.Expectations;
-import org.jmock.Mockery;
-import org.jmock.lib.legacy.ClassImposteriser;
-import org.junit.Test;
-
-import java.security.SecureRandom;
-
-import static org.briarproject.bramble.test.TestUtils.getAuthor;
-import static org.briarproject.bramble.test.TestUtils.getGroup;
-import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
-import static org.briarproject.bramble.test.TestUtils.getRandomId;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.AWAIT_RESPONSES;
-import static org.briarproject.briar.api.introduction.IntroducerProtocolState.PREPARE_REQUESTS;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.AUTHOR_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.CONTACT_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MESSAGE_TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STATE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.STORAGE_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
-import static org.junit.Assert.assertFalse;
-
-public class IntroducerManagerTest extends BriarTestCase {
-
-	private final Mockery context;
-	private final IntroducerManager introducerManager;
-	private final CryptoComponent cryptoComponent;
-	private final ClientHelper clientHelper;
-	private final IntroductionGroupFactory introductionGroupFactory;
-	private final MessageSender messageSender;
-	private final Clock clock;
-	private final Contact introducee1;
-	private final Contact introducee2;
-	private final Group localGroup0;
-	private final Group introductionGroup1;
-	private final Group introductionGroup2;
-
-	public IntroducerManagerTest() {
-		context = new Mockery();
-		context.setImposteriser(ClassImposteriser.INSTANCE);
-		messageSender = context.mock(MessageSender.class);
-		cryptoComponent = context.mock(CryptoComponent.class);
-		clientHelper = context.mock(ClientHelper.class);
-		clock = context.mock(Clock.class);
-		introductionGroupFactory =
-				context.mock(IntroductionGroupFactory.class);
-
-		introducerManager =
-				new IntroducerManager(messageSender, clientHelper, clock,
-						cryptoComponent, introductionGroupFactory);
-
-		Author author1 = getAuthor();
-		AuthorId localAuthorId1 = new AuthorId(getRandomId());
-		ContactId contactId1 = new ContactId(234);
-		introducee1 =
-				new Contact(contactId1, author1, localAuthorId1, true, true);
-
-		Author author2 = getAuthor();
-		AuthorId localAuthorId2 = new AuthorId(getRandomId());
-		ContactId contactId2 = new ContactId(235);
-		introducee2 =
-				new Contact(contactId2, author2, localAuthorId2, true, true);
-
-		localGroup0 = getGroup(CLIENT_ID);
-		introductionGroup1 = getGroup(CLIENT_ID);
-		introductionGroup2 = getGroup(CLIENT_ID);
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testMakeIntroduction() throws DbException, FormatException {
-		Transaction txn = new Transaction(null, false);
-		long time = 42L;
-		context.setImposteriser(ClassImposteriser.INSTANCE);
-		SecureRandom secureRandom = context.mock(SecureRandom.class);
-		Bytes salt = new Bytes(new byte[64]);
-		Message msg = new Message(new MessageId(getRandomId()),
-				localGroup0.getId(), time, getRandomBytes(64));
-		BdfDictionary state = new BdfDictionary();
-		state.put(SESSION_ID, msg.getId());
-		state.put(STORAGE_ID, msg.getId());
-		state.put(STATE, PREPARE_REQUESTS.getValue());
-		state.put(ROLE, ROLE_INTRODUCER);
-		state.put(GROUP_ID_1, introductionGroup1.getId());
-		state.put(GROUP_ID_2, introductionGroup2.getId());
-		state.put(CONTACT_1, introducee1.getAuthor().getName());
-		state.put(CONTACT_2, introducee2.getAuthor().getName());
-		state.put(CONTACT_ID_1, introducee1.getId().getInt());
-		state.put(CONTACT_ID_2, introducee2.getId().getInt());
-		state.put(AUTHOR_ID_1, introducee1.getAuthor().getId());
-		state.put(AUTHOR_ID_2, introducee2.getAuthor().getId());
-		BdfDictionary state2 = (BdfDictionary) state.clone();
-		state2.put(STATE, AWAIT_RESPONSES.getValue());
-
-		BdfDictionary msg1 = new BdfDictionary();
-		msg1.put(TYPE, TYPE_REQUEST);
-		msg1.put(SESSION_ID, state.getRaw(SESSION_ID));
-		msg1.put(GROUP_ID, state.getRaw(GROUP_ID_1));
-		msg1.put(NAME, state.getString(CONTACT_2));
-		msg1.put(PUBLIC_KEY, introducee2.getAuthor().getPublicKey());
-		BdfDictionary msg1send = (BdfDictionary) msg1.clone();
-		msg1send.put(MESSAGE_TIME, time);
-
-		BdfDictionary msg2 = new BdfDictionary();
-		msg2.put(TYPE, TYPE_REQUEST);
-		msg2.put(SESSION_ID, state.getRaw(SESSION_ID));
-		msg2.put(GROUP_ID, state.getRaw(GROUP_ID_2));
-		msg2.put(NAME, state.getString(CONTACT_1));
-		msg2.put(PUBLIC_KEY, introducee1.getAuthor().getPublicKey());
-		BdfDictionary msg2send = (BdfDictionary) msg2.clone();
-		msg2send.put(MESSAGE_TIME, time);
-
-		context.checking(new Expectations() {{
-			// initialize and store session state
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(time));
-			oneOf(cryptoComponent).getSecureRandom();
-			will(returnValue(secureRandom));
-			oneOf(secureRandom).nextBytes(salt.getBytes());
-			oneOf(introductionGroupFactory).createLocalGroup();
-			will(returnValue(localGroup0));
-			oneOf(clientHelper).createMessage(localGroup0.getId(), time,
-					BdfList.of(salt));
-			will(returnValue(msg));
-			oneOf(introductionGroupFactory)
-					.createIntroductionGroup(introducee1);
-			will(returnValue(introductionGroup1));
-			oneOf(introductionGroupFactory)
-					.createIntroductionGroup(introducee2);
-			will(returnValue(introductionGroup2));
-			oneOf(clientHelper).addLocalMessage(txn, msg, state, false);
-
-			// send message
-			oneOf(clientHelper).mergeMessageMetadata(txn, msg.getId(), state2);
-			oneOf(messageSender).sendMessage(txn, msg1send);
-			oneOf(messageSender).sendMessage(txn, msg2send);
-		}});
-
-		introducerManager
-				.makeIntroduction(txn, introducee1, introducee2, null, time);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionCryptoIntegrationTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionCryptoIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..81c2ae01c3c6a80389b5bc793615006f4a6df1a2
--- /dev/null
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionCryptoIntegrationTest.java
@@ -0,0 +1,161 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.crypto.CryptoComponent;
+import org.briarproject.bramble.api.crypto.KeyPair;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorFactory;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.test.BrambleTestCase;
+import org.briarproject.briar.api.client.SessionId;
+import org.junit.Test;
+
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import static org.briarproject.bramble.test.TestUtils.getSecretKey;
+import static org.briarproject.bramble.test.TestUtils.getTransportPropertiesMap;
+import static org.briarproject.briar.introduction.IntroduceeSession.Local;
+import static org.briarproject.briar.introduction.IntroduceeSession.Remote;
+import static org.briarproject.briar.test.BriarTestUtils.getRealAuthor;
+import static org.briarproject.briar.test.BriarTestUtils.getRealLocalAuthor;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+public class IntroductionCryptoIntegrationTest extends BrambleTestCase {
+
+	@Inject
+	ClientHelper clientHelper;
+	@Inject
+	AuthorFactory authorFactory;
+	@Inject
+	CryptoComponent cryptoComponent;
+
+	private final IntroductionCryptoImpl crypto;
+
+	private final Author introducer;
+	private final LocalAuthor alice, bob;
+	private final long aliceAcceptTimestamp = 42L;
+	private final long bobAcceptTimestamp = 1337L;
+	private final SecretKey masterKey = getSecretKey();
+	private final KeyPair aliceEphemeral, bobEphemeral;
+	private final Map<TransportId, TransportProperties> aliceTransport =
+			getTransportPropertiesMap(3);
+	private final Map<TransportId, TransportProperties> bobTransport =
+			getTransportPropertiesMap(3);
+
+	public IntroductionCryptoIntegrationTest() {
+		IntroductionIntegrationTestComponent component =
+				DaggerIntroductionIntegrationTestComponent.builder().build();
+		component.inject(this);
+		crypto = new IntroductionCryptoImpl(cryptoComponent, clientHelper);
+
+		introducer = getRealAuthor(authorFactory);
+		LocalAuthor introducee1 =
+				getRealLocalAuthor(cryptoComponent, authorFactory);
+		LocalAuthor introducee2 =
+				getRealLocalAuthor(cryptoComponent, authorFactory);
+		boolean isAlice =
+				crypto.isAlice(introducee1.getId(), introducee2.getId());
+		alice = isAlice ? introducee1 : introducee2;
+		bob = isAlice ? introducee2 : introducee1;
+		aliceEphemeral = crypto.generateKeyPair();
+		bobEphemeral = crypto.generateKeyPair();
+	}
+
+	@Test
+	public void testGetSessionId() {
+		SessionId s1 = crypto.getSessionId(introducer, alice, bob);
+		SessionId s2 = crypto.getSessionId(introducer, bob, alice);
+		assertEquals(s1, s2);
+
+		SessionId s3 = crypto.getSessionId(alice, bob, introducer);
+		assertNotEquals(s1, s3);
+	}
+
+	@Test
+	public void testIsAlice() {
+		assertTrue(crypto.isAlice(alice.getId(), bob.getId()));
+		assertFalse(crypto.isAlice(bob.getId(), alice.getId()));
+	}
+
+	@Test
+	public void testDeriveMasterKey() throws Exception {
+		SecretKey aliceMasterKey =
+				crypto.deriveMasterKey(aliceEphemeral.getPublic().getEncoded(),
+						aliceEphemeral.getPrivate().getEncoded(),
+						bobEphemeral.getPublic().getEncoded(), true);
+		SecretKey bobMasterKey =
+				crypto.deriveMasterKey(bobEphemeral.getPublic().getEncoded(),
+						bobEphemeral.getPrivate().getEncoded(),
+						aliceEphemeral.getPublic().getEncoded(), false);
+		assertArrayEquals(aliceMasterKey.getBytes(), bobMasterKey.getBytes());
+	}
+
+	@Test
+	public void testAliceAuthMac() throws Exception {
+		SecretKey aliceMacKey = crypto.deriveMacKey(masterKey, true);
+		Local local = new Local(true, null, -1,
+				aliceEphemeral.getPublic().getEncoded(),
+				aliceEphemeral.getPrivate().getEncoded(), aliceTransport,
+				aliceAcceptTimestamp, aliceMacKey.getBytes());
+		Remote remote = new Remote(false, bob, null,
+				bobEphemeral.getPublic().getEncoded(), bobTransport,
+				bobAcceptTimestamp, null);
+		byte[] aliceMac =
+				crypto.authMac(aliceMacKey, introducer.getId(), alice.getId(),
+						local, remote);
+
+		// verify from Bob's perspective
+		crypto.verifyAuthMac(aliceMac, aliceMacKey, introducer.getId(),
+				bob.getId(), remote, alice.getId(), local);
+	}
+
+	@Test
+	public void testBobAuthMac() throws Exception {
+		SecretKey bobMacKey = crypto.deriveMacKey(masterKey, false);
+		Local local = new Local(false, null, -1,
+				bobEphemeral.getPublic().getEncoded(),
+				bobEphemeral.getPrivate().getEncoded(), bobTransport,
+				bobAcceptTimestamp, bobMacKey.getBytes());
+		Remote remote = new Remote(true, alice, null,
+				aliceEphemeral.getPublic().getEncoded(), aliceTransport,
+				aliceAcceptTimestamp, null);
+		byte[] bobMac =
+				crypto.authMac(bobMacKey, introducer.getId(), bob.getId(),
+						local, remote);
+
+		// verify from Alice's perspective
+		crypto.verifyAuthMac(bobMac, bobMacKey, introducer.getId(),
+				alice.getId(), remote, bob.getId(), local);
+	}
+
+	@Test
+	public void testSign() throws Exception {
+		SecretKey macKey = crypto.deriveMacKey(masterKey, true);
+		byte[] signature = crypto.sign(macKey, alice.getPrivateKey());
+		crypto.verifySignature(macKey, alice.getPublicKey(), signature);
+	}
+
+	@Test
+	public void testAliceActivateMac() throws Exception {
+		SecretKey aliceMacKey = crypto.deriveMacKey(masterKey, true);
+		byte[] aliceMac = crypto.activateMac(aliceMacKey);
+		crypto.verifyActivateMac(aliceMac, aliceMacKey);
+	}
+
+	@Test
+	public void testBobActivateMac() throws Exception {
+		SecretKey bobMacKey = crypto.deriveMacKey(masterKey, false);
+		byte[] bobMac = crypto.activateMac(bobMacKey);
+		crypto.verifyActivateMac(bobMac, bobMacKey);
+	}
+
+}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionCryptoTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionCryptoTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f966aa2cb06531c1c3b241bdd112c10d23620f0b
--- /dev/null
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionCryptoTest.java
@@ -0,0 +1,45 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.crypto.CryptoComponent;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.test.BrambleMockTestCase;
+import org.briarproject.briar.api.client.SessionId;
+import org.jmock.Expectations;
+import org.junit.Test;
+
+import static org.briarproject.bramble.test.TestUtils.getAuthor;
+import static org.briarproject.bramble.test.TestUtils.getRandomId;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.LABEL_SESSION_ID;
+import static org.junit.Assert.assertEquals;
+
+public class IntroductionCryptoTest extends BrambleMockTestCase {
+
+	private final CryptoComponent cryptoComponent =
+			context.mock(CryptoComponent.class);
+	private final ClientHelper clientHelper = context.mock(ClientHelper.class);
+
+	private final IntroductionCrypto crypto =
+			new IntroductionCryptoImpl(cryptoComponent, clientHelper);
+
+	private final Author introducer = getAuthor();
+	private final Author alice = getAuthor(), bob = getAuthor();
+	private final byte[] hash = getRandomId();
+
+	@Test
+	public void testGetSessionId() {
+		boolean isAlice = crypto.isAlice(alice.getId(), bob.getId());
+		context.checking(new Expectations() {{
+			oneOf(cryptoComponent).hash(
+					LABEL_SESSION_ID,
+					introducer.getId().getBytes(),
+					isAlice ? alice.getId().getBytes() : bob.getId().getBytes(),
+					isAlice ? bob.getId().getBytes() : alice.getId().getBytes()
+			);
+			will(returnValue(hash));
+		}});
+		SessionId sessionId = crypto.getSessionId(introducer, alice, bob);
+		assertEquals(new SessionId(hash), sessionId);
+	}
+
+}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTest.java
index 9b485e5c42b1ce13b9ab9414cb90eb48de20432d..df0d46b8811e60e6f8f58919ef388a590fd4363e 100644
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTest.java
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTest.java
@@ -6,25 +6,24 @@ import org.briarproject.bramble.api.FormatException;
 import org.briarproject.bramble.api.client.ClientHelper;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.crypto.KeyPair;
-import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.data.BdfDictionary;
 import org.briarproject.bramble.api.data.BdfEntry;
 import org.briarproject.bramble.api.data.BdfList;
 import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Metadata;
 import org.briarproject.bramble.api.db.Transaction;
 import org.briarproject.bramble.api.event.Event;
 import org.briarproject.bramble.api.event.EventListener;
+import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.properties.TransportPropertyManager;
 import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
 import org.briarproject.bramble.api.sync.MessageId;
 import org.briarproject.bramble.test.TestDatabaseModule;
+import org.briarproject.briar.api.client.ProtocolStateException;
 import org.briarproject.briar.api.client.SessionId;
 import org.briarproject.briar.api.introduction.IntroductionManager;
 import org.briarproject.briar.api.introduction.IntroductionMessage;
@@ -38,56 +37,43 @@ import org.junit.Before;
 import org.junit.Test;
 
 import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.concurrent.TimeoutException;
 import java.util.logging.Logger;
 
-import javax.inject.Inject;
-
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
 import static org.briarproject.bramble.test.TestPluginConfigModule.TRANSPORT_ID;
 import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
 import static org.briarproject.bramble.test.TestUtils.getTransportId;
-import static org.briarproject.bramble.util.StringUtils.getRandomString;
-import static org.briarproject.briar.api.client.MessageQueueManager.QUEUE_STATE_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ALICE_MAC_KEY_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ALICE_NONCE_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NONCE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SHARED_SECRET_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNING_LABEL;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
+import static org.briarproject.bramble.test.TestUtils.getTransportProperties;
+import static org.briarproject.bramble.test.TestUtils.getTransportPropertiesMap;
+import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
 import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_VERSION;
+import static org.briarproject.briar.introduction.IntroduceeState.LOCAL_DECLINED;
+import static org.briarproject.briar.introduction.IntroducerState.A_DECLINED;
+import static org.briarproject.briar.introduction.IntroducerState.B_DECLINED;
+import static org.briarproject.briar.introduction.IntroducerState.START;
+import static org.briarproject.briar.introduction.IntroductionConstants.MSG_KEY_MESSAGE_TYPE;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_AUTHOR;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCEE_A;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_INTRODUCEE_B;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_LAST_LOCAL_MESSAGE_ID;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_SESSION_ID;
+import static org.briarproject.briar.introduction.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction.MessageType.AUTH;
+import static org.briarproject.briar.introduction.MessageType.DECLINE;
 import static org.briarproject.briar.test.BriarTestUtils.assertGroupCount;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 public class IntroductionIntegrationTest
 		extends BriarIntegrationTest<IntroductionIntegrationTestComponent> {
 
-	@Inject
-	IntroductionGroupFactory introductionGroupFactory;
-
 	// objects accessed from background threads need to be volatile
 	private volatile IntroductionManager introductionManager0;
 	private volatile IntroductionManager introductionManager1;
@@ -102,7 +88,7 @@ public class IntroductionIntegrationTest
 			Logger.getLogger(IntroductionIntegrationTest.class.getName());
 
 	interface StateVisitor {
-		boolean visit(BdfDictionary response);
+		AcceptMessage visit(AcceptMessage response);
 	}
 
 	@Before
@@ -151,50 +137,61 @@ public class IntroductionIntegrationTest
 				.makeIntroduction(introducee1, introducee2, "Hi!", time);
 
 		// check that messages are tracked properly
-		Group g1 = introductionGroupFactory
-				.createIntroductionGroup(introducee1);
-		Group g2 = introductionGroupFactory
-				.createIntroductionGroup(introducee2);
-		assertGroupCount(messageTracker0, g1.getId(), 1, 0, time);
-		assertGroupCount(messageTracker0, g2.getId(), 1, 0, time);
+		Group g1 = introductionManager0.getContactGroup(introducee1);
+		Group g2 = introductionManager0.getContactGroup(introducee2);
+		assertGroupCount(messageTracker0, g1.getId(), 1, 0);
+		assertGroupCount(messageTracker0, g2.getId(), 1, 0);
 
-		// sync first request message
+		// sync first REQUEST message
 		sync0To1(1, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener1.requestReceived);
 		assertGroupCount(messageTracker1, g1.getId(), 2, 1);
 
-		// sync second request message
+		// sync second REQUEST message
 		sync0To2(1, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener2.requestReceived);
 		assertGroupCount(messageTracker2, g2.getId(), 2, 1);
 
-		// sync first response
+		// sync first ACCEPT message
 		sync1To0(1, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener0.response1Received);
 		assertGroupCount(messageTracker0, g1.getId(), 2, 1);
 
-		// sync second response
+		// sync second ACCEPT message
 		sync2To0(1, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener0.response2Received);
 		assertGroupCount(messageTracker0, g2.getId(), 2, 1);
 
-		// sync forwarded responses to introducees
+		// sync forwarded ACCEPT messages to introducees
 		sync0To1(1, true);
 		sync0To2(1, true);
-		assertGroupCount(messageTracker1, g1.getId(), 2, 1);
-		assertGroupCount(messageTracker2, g2.getId(), 2, 1);
 
-		// sync first ACK and its forward
+		// sync first AUTH and its forward
 		sync1To0(1, true);
 		sync0To2(1, true);
 
-		// sync second ACK and its forward
-		sync2To0(1, true);
-		sync0To1(1, true);
+		// assert that introducee2 did add the transport keys
+		IntroduceeSession session2 = getIntroduceeSession(c2);
+		assertNotNull(session2.getTransportKeys());
+		assertFalse(session2.getTransportKeys().isEmpty());
+
+		// sync second AUTH and its forward as well as the following ACTIVATE
+		sync2To0(2, true);
+		sync0To1(2, true);
+
+		// assert that introducee1 really purged the key material
+		IntroduceeSession session1 = getIntroduceeSession(c1);
+		assertNull(session1.getMasterKey());
+		assertNull(session1.getLocal().ephemeralPrivateKey);
+		assertNull(session1.getTransportKeys());
+
+		// sync second ACTIVATE and its forward
+		sync1To0(1, true);
+		sync0To2(1, true);
 
 		// wait for introduction to succeed
 		eventWaiter.await(TIMEOUT, 2);
@@ -245,16 +242,32 @@ public class IntroductionIntegrationTest
 		assertTrue(listener1.requestReceived);
 		assertTrue(listener2.requestReceived);
 
+		// assert that introducee is in correct state
+		IntroduceeSession introduceeSession = getIntroduceeSession(c1);
+		assertEquals(LOCAL_DECLINED, introduceeSession.getState());
+
 		// sync first response
 		sync1To0(1, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener0.response1Received);
 
+		// assert that introducer is in correct state
+		boolean alice = c0.getIntroductionCrypto()
+				.isAlice(introducee1.getAuthor().getId(),
+						introducee2.getAuthor().getId());
+		IntroducerSession introducerSession = getIntroducerSession();
+		assertEquals(alice ? A_DECLINED : B_DECLINED,
+				introducerSession.getState());
+
 		// sync second response
 		sync2To0(1, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener0.response2Received);
 
+		// assert that introducer now moved to START state
+		introducerSession = getIntroducerSession();
+		assertEquals(START, introducerSession.getState());
+
 		// sync first forwarded response
 		sync0To2(1, true);
 
@@ -269,10 +282,8 @@ public class IntroductionIntegrationTest
 		assertFalse(contactManager2
 				.contactExists(author1.getId(), author2.getId()));
 
-		Group g1 = introductionGroupFactory
-				.createIntroductionGroup(introducee1);
-		Group g2 = introductionGroupFactory
-				.createIntroductionGroup(introducee2);
+		Group g1 = introductionManager0.getContactGroup(introducee1);
+		Group g2 = introductionManager0.getContactGroup(introducee2);
 		assertEquals(2,
 				introductionManager0.getIntroductionMessages(contactId1From0)
 						.size());
@@ -290,6 +301,10 @@ public class IntroductionIntegrationTest
 				introductionManager2.getIntroductionMessages(contactId0From2)
 						.size());
 		assertGroupCount(messageTracker2, g2.getId(), 3, 2);
+
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
 	}
 
 	@Test
@@ -342,6 +357,9 @@ public class IntroductionIntegrationTest
 		assertEquals(2,
 				introductionManager2.getIntroductionMessages(contactId0From2)
 						.size());
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
 	}
 
 	@Test
@@ -393,6 +411,9 @@ public class IntroductionIntegrationTest
 		// since introducee2 was already in FINISHED state when
 		// introducee1's response arrived, she ignores and deletes it
 		assertDefaultUiMessages();
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
 	}
 
 	@Test
@@ -425,13 +446,16 @@ public class IntroductionIntegrationTest
 
 		// answer request manually
 		introductionManager2
-				.acceptIntroduction(contactId0From2, listener2.sessionId, time);
+				.respondToIntroduction(contactId0From2, listener2.sessionId, time,
+						true);
 
 		// sync second response and ACK and make sure there is no abort
 		sync2To0(2, true);
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener0.response2Received);
 		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
 	}
 
 	@Test
@@ -452,61 +476,290 @@ public class IntroductionIntegrationTest
 		// make really sure we don't have that request
 		assertTrue(introductionManager1.getIntroductionMessages(contactId0From1)
 				.isEmpty());
+
+		// The message was invalid, so no abort message was sent
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testDoubleIntroduction() throws Exception {
+		// we can make an introduction
+		assertTrue(introductionManager0
+				.canIntroduce(contact1From0, contact2From0));
+
+		// make the introduction
+		long time = clock.currentTimeMillis();
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
+
+		// no more introduction allowed while the existing one is in progress
+		assertFalse(introductionManager0
+				.canIntroduce(contact1From0, contact2From0));
+
+		// try it anyway and fail
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
 	}
 
 	@Test
-	public void testSessionIdReuse() throws Exception {
+	public void testIntroductionToExistingContact() throws Exception {
+		// let contact1 and contact2 add each other already
+		addContacts1And2();
+		assertNotNull(contactId2From1);
+		assertNotNull(contactId1From2);
+
+		// both will still accept the introduction
 		addListeners(true, true);
 
-		// make introduction
+		// make the introduction
 		long time = clock.currentTimeMillis();
 		introductionManager0
-				.makeIntroduction(contact1From0, contact2From0, "Hi!", time);
+				.makeIntroduction(contact1From0, contact2From0, null, time);
 
-		// sync first request message
+		// sync REQUEST messages
 		sync0To1(1, true);
-		eventWaiter.await(TIMEOUT, 1);
-		assertTrue(listener1.requestReceived);
+		sync0To2(1, true);
 
-		// get SessionId
-		List<IntroductionMessage> list = new ArrayList<>(
-				introductionManager1.getIntroductionMessages(contactId0From1));
-		assertEquals(2, list.size());
-		assertTrue(list.get(0) instanceof IntroductionRequest);
-		IntroductionRequest msg = (IntroductionRequest) list.get(0);
-		SessionId sessionId = msg.getSessionId();
-
-		// get contact group
-		Group group =
-				introductionGroupFactory.createIntroductionGroup(contact1From0);
-
-		// create new message with same SessionId
-		BdfDictionary d = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_REQUEST),
-				new BdfEntry(SESSION_ID, sessionId),
-				new BdfEntry(GROUP_ID, group.getId()),
-				new BdfEntry(NAME, getRandomString(42)),
-				new BdfEntry(PUBLIC_KEY, getRandomBytes(MAX_PUBLIC_KEY_LENGTH))
-		);
+		// assert that introducees get notified about the existing contact
+		IntroductionRequest ir1 =
+				getIntroductionRequest(introductionManager1, contactId0From1);
+		assertTrue(ir1.contactExists());
+		IntroductionRequest ir2 =
+				getIntroductionRequest(introductionManager2, contactId0From2);
+		assertTrue(ir2.contactExists());
 
-		// reset request received state
-		listener1.requestReceived = false;
+		// sync ACCEPT messages back to introducer
+		sync1To0(1, true);
+		sync2To0(1, true);
 
-		// add the message to the queue
-		MessageSender sender0 = c0.getMessageSender();
-		Transaction txn = db0.startTransaction(false);
-		try {
-			sender0.sendMessage(txn, d);
-			db0.commitTransaction(txn);
-		} finally {
-			db0.endTransaction(txn);
-		}
+		// sync forwarded ACCEPT messages to introducees
+		sync0To1(1, true);
+		sync0To2(1, true);
 
-		// actually send message
-		sync0To1(1, false);
+		// sync first AUTH and its forward
+		sync1To0(1, true);
+		sync0To2(1, true);
 
-		// make sure it does not arrive
-		assertFalse(listener1.requestReceived);
+		// sync second AUTH and its forward as well as the following ACTIVATE
+		sync2To0(2, true);
+		sync0To1(2, true);
+
+		// sync second ACTIVATE and its forward
+		sync1To0(1, true);
+		sync0To2(1, true);
+
+		// assert that no session was aborted and no success event was broadcast
+		assertFalse(listener1.succeeded);
+		assertFalse(listener2.succeeded);
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
+	}
+
+	@Test
+	public void testIntroductionToRemovedContact() throws Exception {
+		// let contact1 and contact2 add each other
+		addContacts1And2();
+		assertNotNull(contactId2From1);
+		assertNotNull(contactId1From2);
+
+		// only introducee1 removes introducee2
+		contactManager1.removeContact(contactId2From1);
+
+		// both will accept the introduction
+		addListeners(true, true);
+
+		// make the introduction
+		long time = clock.currentTimeMillis();
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
+
+		// sync REQUEST messages
+		sync0To1(1, true);
+		sync0To2(1, true);
+
+		// sync ACCEPT messages back to introducer
+		sync1To0(1, true);
+		sync2To0(1, true);
+
+		// sync forwarded ACCEPT messages to introducees
+		sync0To1(1, true);
+		sync0To2(1, true);
+
+		// sync first AUTH and its forward
+		sync1To0(1, true);
+		sync0To2(1, true);
+
+		// sync second AUTH and its forward as well as the following ACTIVATE
+		sync2To0(2, true);
+		sync0To1(2, true);
+
+		// sync second ACTIVATE and its forward
+		sync1To0(1, true);
+		sync0To2(1, true);
+
+		// Introduction only succeeded for introducee1
+		assertTrue(listener1.succeeded);
+		assertFalse(listener2.succeeded);
+
+		// assert that no session was aborted
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertFalse(listener2.aborted);
+	}
+
+	/**
+	 * One introducee illegally sends two ACCEPT messages in a row.
+	 * The introducer should notice this and ABORT the session.
+	 */
+	@Test
+	public void testDoubleAccept() throws Exception {
+		addListeners(true, true);
+
+		// make the introduction
+		long time = clock.currentTimeMillis();
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
+
+		// sync REQUEST to introducee1
+		sync0To1(1, true);
+
+		// save ACCEPT from introducee1
+		AcceptMessage m = (AcceptMessage) getMessageFor(c1.getClientHelper(),
+				contact0From1, ACCEPT);
+
+		// sync ACCEPT back to introducer
+		sync1To0(1, true);
+
+		// fake a second ACCEPT message from introducee1
+		Message msg = c1.getMessageEncoder()
+				.encodeAcceptMessage(m.getGroupId(), m.getTimestamp() + 1,
+						m.getMessageId(), m.getSessionId(),
+						m.getEphemeralPublicKey(), m.getAcceptTimestamp(),
+						m.getTransportProperties());
+		c1.getClientHelper().addLocalMessage(msg, new BdfDictionary(), true);
+
+		// sync fake ACCEPT back to introducer
+		sync1To0(1, true);
+
+		assertTrue(listener0.aborted);
+	}
+
+	/**
+	 * One introducee sends an ACCEPT and then another DECLINE message.
+	 * The introducer should notice this and ABORT the session.
+	 */
+	@Test
+	public void testAcceptAndDecline() throws Exception {
+		addListeners(true, true);
+
+		// make the introduction
+		long time = clock.currentTimeMillis();
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
+
+		// sync REQUEST to introducee1
+		sync0To1(1, true);
+
+		// save ACCEPT from introducee1
+		AcceptMessage m = (AcceptMessage) getMessageFor(c1.getClientHelper(),
+				contact0From1, ACCEPT);
+
+		// sync ACCEPT back to introducer
+		sync1To0(1, true);
+
+		// fake a second DECLINE message also from introducee1
+		Message msg = c1.getMessageEncoder()
+				.encodeDeclineMessage(m.getGroupId(), m.getTimestamp() + 1,
+						m.getMessageId(), m.getSessionId());
+		c1.getClientHelper().addLocalMessage(msg, new BdfDictionary(), true);
+
+		// sync fake DECLINE back to introducer
+		sync1To0(1, true);
+
+		assertTrue(listener0.aborted);
+	}
+
+	/**
+	 * One introducee sends an DECLINE and then another DECLINE message.
+	 * The introducer should notice this and ABORT the session.
+	 */
+	@Test
+	public void testDoubleDecline() throws Exception {
+		addListeners(false, true);
+
+		// make the introduction
+		long time = clock.currentTimeMillis();
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
+
+		// sync REQUEST to introducee1
+		sync0To1(1, true);
+
+		// save DECLINE from introducee1
+		DeclineMessage m = (DeclineMessage) getMessageFor(c1.getClientHelper(),
+				contact0From1, DECLINE);
+
+		// sync DECLINE back to introducer
+		sync1To0(1, true);
+
+		// fake a second DECLINE message also from introducee1
+		Message msg = c1.getMessageEncoder()
+				.encodeDeclineMessage(m.getGroupId(), m.getTimestamp() + 1,
+						m.getMessageId(), m.getSessionId());
+		c1.getClientHelper().addLocalMessage(msg, new BdfDictionary(), true);
+
+		// sync fake DECLINE back to introducer
+		sync1To0(1, true);
+
+		assertTrue(listener0.aborted);
+	}
+
+	/**
+	 * One introducee sends two AUTH messages.
+	 * The introducer should notice this and ABORT the session.
+	 */
+	@Test
+	public void testDoubleAuth() throws Exception {
+		addListeners(true, true);
+
+		// make the introduction
+		long time = clock.currentTimeMillis();
+		introductionManager0
+				.makeIntroduction(contact1From0, contact2From0, null, time);
+
+		// sync REQUEST messages
+		sync0To1(1, true);
+		sync0To2(1, true);
+
+		// sync ACCEPT messages
+		sync1To0(1, true);
+		sync2To0(1, true);
+
+		// sync forwarded ACCEPT messages to introducees
+		sync0To1(1, true);
+		sync0To2(1, true);
+
+		// save AUTH from introducee1
+		AuthMessage m = (AuthMessage) getMessageFor(c1.getClientHelper(),
+				contact0From1, AUTH);
+
+		// sync first AUTH message
+		sync1To0(1, true);
+
+		// fake a second AUTH message also from introducee1
+		Message msg = c1.getMessageEncoder()
+				.encodeAuthMessage(m.getGroupId(), m.getTimestamp() + 1,
+						m.getMessageId(), m.getSessionId(), m.getMac(),
+						m.getSignature());
+		c1.getClientHelper().addLocalMessage(msg, new BdfDictionary(), true);
+
+		// sync second AUTH message
+		sync1To0(1, true);
+
+		assertTrue(listener0.aborted);
 	}
 
 	@Test
@@ -523,34 +776,19 @@ public class IntroductionIntegrationTest
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener1.requestReceived);
 
-		// get database and local group for introducee
-		Group group1 = introductionGroupFactory.createLocalGroup();
+		// get local group for introducee1
+		Group group1 = getLocalGroup();
 
-		// get local session state messages
-		Map<MessageId, Metadata> map;
-		Transaction txn = db1.startTransaction(false);
-		try {
-			map = db1.getMessageMetadata(txn, group1.getId());
-			db1.commitTransaction(txn);
-		} finally {
-			db1.endTransaction(txn);
-		}
 		// check that we have one session state
-		assertEquals(1, map.size());
+		assertEquals(1, c1.getClientHelper()
+				.getMessageMetadataAsDictionary(group1.getId()).size());
 
 		// introducee1 removes introducer
 		contactManager1.removeContact(contactId0From1);
 
-		// get local session state messages again
-		txn = db1.startTransaction(false);
-		try {
-			map = db1.getMessageMetadata(txn, group1.getId());
-			db1.commitTransaction(txn);
-		} finally {
-			db1.endTransaction(txn);
-		}
 		// make sure local state got deleted
-		assertEquals(0, map.size());
+		assertEquals(0, c1.getClientHelper()
+				.getMessageMetadataAsDictionary(group1.getId()).size());
 	}
 
 	@Test
@@ -567,48 +805,35 @@ public class IntroductionIntegrationTest
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener1.requestReceived);
 
-		// get database and local group for introducee
-		Group group1 = introductionGroupFactory.createLocalGroup();
+		// get local group for introducer
+		Group group0 = getLocalGroup();
 
-		// get local session state messages
-		Map<MessageId, Metadata> map;
-		Transaction txn = db0.startTransaction(false);
-		try {
-			map = db0.getMessageMetadata(txn, group1.getId());
-			db0.commitTransaction(txn);
-		} finally {
-			db0.endTransaction(txn);
-		}
 		// check that we have one session state
-		assertEquals(1, map.size());
+		assertEquals(1, c0.getClientHelper()
+				.getMessageMetadataAsDictionary(group0.getId()).size());
 
 		// introducer removes introducee1
 		contactManager0.removeContact(contactId1From0);
 
-		// get local session state messages again
-		txn = db0.startTransaction(false);
-		try {
-			map = db0.getMessageMetadata(txn, group1.getId());
-			db0.commitTransaction(txn);
-		} finally {
-			db0.endTransaction(txn);
-		}
 		// make sure local state is still there
-		assertEquals(1, map.size());
+		assertEquals(1, c0.getClientHelper()
+				.getMessageMetadataAsDictionary(group0.getId()).size());
+
+		// ensure introducer has aborted the session
+		assertTrue(listener0.aborted);
+
+		// sync REQUEST and ABORT message
+		sync0To2(2, true);
+
+		// ensure introducee2 has aborted the session as well
+		assertTrue(listener2.aborted);
 
 		// introducer removes other introducee
 		contactManager0.removeContact(contactId2From0);
 
-		// get local session state messages again
-		txn = db0.startTransaction(false);
-		try {
-			map = db0.getMessageMetadata(txn, group1.getId());
-			db0.commitTransaction(txn);
-		} finally {
-			db0.endTransaction(txn);
-		}
 		// make sure local state is gone now
-		assertEquals(0, map.size());
+		assertEquals(0, c0.getClientHelper()
+				.getMessageMetadataAsDictionary(group0.getId()).size());
 	}
 
 	private void testModifiedResponse(StateVisitor visitor)
@@ -630,26 +855,35 @@ public class IntroductionIntegrationTest
 		eventWaiter.await(TIMEOUT, 1);
 
 		// get response to be forwarded
-		ClientHelper ch = c0.getClientHelper(); // need 0's ClientHelper here
-		Entry<MessageId, BdfDictionary> resp =
-				getMessageFor(ch, contact2From0, TYPE_RESPONSE);
-		MessageId responseId = resp.getKey();
-		BdfDictionary response = resp.getValue();
-
-		// adapt outgoing message queue to removed message
-		Group g2 = introductionGroupFactory
-				.createIntroductionGroup(contact2From0);
-		decreaseOutgoingMessageCounter(ch, g2.getId());
+		AcceptMessage message =
+				(AcceptMessage) getMessageFor(c0.getClientHelper(),
+						contact2From0, ACCEPT);
 
 		// allow visitor to modify response
-		boolean earlyAbort = visitor.visit(response);
+		AcceptMessage m = visitor.visit(message);
 
 		// replace original response with modified one
-		MessageSender sender0 = c0.getMessageSender();
 		Transaction txn = db0.startTransaction(false);
 		try {
-			db0.deleteMessage(txn, responseId);
-			sender0.sendMessage(txn, response);
+			db0.removeMessage(txn, message.getMessageId());
+			Message msg = c0.getMessageEncoder()
+					.encodeAcceptMessage(m.getGroupId(), m.getTimestamp(),
+							m.getPreviousMessageId(), m.getSessionId(),
+							m.getEphemeralPublicKey(), m.getAcceptTimestamp(),
+							m.getTransportProperties());
+			c0.getClientHelper()
+					.addLocalMessage(txn, msg, new BdfDictionary(), true);
+			Group group0 = getLocalGroup();
+			BdfDictionary query = BdfDictionary.of(
+					new BdfEntry(SESSION_KEY_SESSION_ID, m.getSessionId())
+			);
+			Map.Entry<MessageId, BdfDictionary> session = c0.getClientHelper()
+					.getMessageMetadataAsDictionary(txn, group0.getId(), query)
+					.entrySet().iterator().next();
+			replacePreviousLocalMessageId(contact2From0.getAuthor(),
+					session.getValue(), msg.getId());
+			c0.getClientHelper().mergeMessageMetadata(txn, session.getKey(),
+					session.getValue());
 			db0.commitTransaction(txn);
 		} finally {
 			db0.endTransaction(txn);
@@ -663,21 +897,14 @@ public class IntroductionIntegrationTest
 		sync0To1(1, true);
 		sync0To2(1, true);
 
-		// sync first ACK and forward it
+		// sync first AUTH and forward it
 		sync1To0(1, true);
 		sync0To2(1, true);
 
 		// introducee2 should have detected the fake now
-		// and deleted introducee1 again
-		Collection<Contact> contacts2;
-		txn = db2.startTransaction(true);
-		try {
-			contacts2 = db2.getContacts(txn);
-			db2.commitTransaction(txn);
-		} finally {
-			db2.endTransaction(txn);
-		}
-		assertEquals(1, contacts2.size());
+		assertFalse(listener0.aborted);
+		assertFalse(listener1.aborted);
+		assertTrue(listener2.aborted);
 
 		// sync introducee2's ack and following abort
 		sync2To0(2, true);
@@ -687,144 +914,44 @@ public class IntroductionIntegrationTest
 
 		// sync abort messages to introducees
 		sync0To1(2, true);
-		sync0To2(1, true);
 
-		if (earlyAbort) {
-			assertTrue(listener1.aborted);
-			assertTrue(listener2.aborted);
-		} else {
-			assertTrue(listener2.aborted);
-			// when aborted late, introducee1 keeps the contact,
-			// so introducer can not make contacts disappear by aborting
-			Collection<Contact> contacts1;
-			txn = db1.startTransaction(true);
-			try {
-				contacts1 = db1.getContacts(txn);
-				db1.commitTransaction(txn);
-			} finally {
-				db1.endTransaction(txn);
-			}
-			assertEquals(2, contacts1.size());
-		}
+		// ensure everybody got the abort now
+		assertTrue(listener0.aborted);
+		assertTrue(listener1.aborted);
+		assertTrue(listener2.aborted);
 	}
 
 	@Test
 	public void testModifiedTransportProperties() throws Exception {
-		testModifiedResponse(response -> {
-			BdfDictionary tp = response.getDictionary(TRANSPORT, null);
-			tp.put("fakeId", BdfDictionary.of(new BdfEntry("fake", "fake")));
-			response.put(TRANSPORT, tp);
-			return false;
-		});
+		testModifiedResponse(
+				m -> new AcceptMessage(m.getMessageId(), m.getGroupId(),
+						m.getTimestamp(), m.getPreviousMessageId(),
+						m.getSessionId(), m.getEphemeralPublicKey(),
+						m.getAcceptTimestamp(),
+						getTransportPropertiesMap(2))
+		);
 	}
 
 	@Test
 	public void testModifiedTimestamp() throws Exception {
-		testModifiedResponse(response -> {
-			long timestamp = response.getLong(TIME, 0L);
-			response.put(TIME, timestamp + 1);
-			return false;
-		});
+		testModifiedResponse(
+				m -> new AcceptMessage(m.getMessageId(), m.getGroupId(),
+						m.getTimestamp(), m.getPreviousMessageId(),
+						m.getSessionId(), m.getEphemeralPublicKey(),
+						clock.currentTimeMillis(),
+						m.getTransportProperties())
+		);
 	}
 
 	@Test
 	public void testModifiedEphemeralPublicKey() throws Exception {
-		testModifiedResponse(response -> {
-			KeyPair keyPair = crypto.generateAgreementKeyPair();
-			response.put(E_PUBLIC_KEY, keyPair.getPublic().getEncoded());
-			return true;
-		});
-	}
-
-	@Test
-	public void testModifiedEphemeralPublicKeyWithFakeMac()
-			throws Exception {
-		// initialize a real introducee manager
-		MessageSender messageSender = c2.getMessageSender();
-		TransportPropertyManager tpManager = c2.getTransportPropertyManager();
-		IntroduceeManager manager2 =
-				new IntroduceeManager(messageSender, db2, clientHelper, clock,
-						crypto, tpManager, authorFactory, contactManager2,
-						identityManager2, introductionGroupFactory);
-
-		// create keys
-		KeyPair keyPair1 = crypto.generateSignatureKeyPair();
-		KeyPair eKeyPair1 = crypto.generateAgreementKeyPair();
-		KeyPair eKeyPair2 = crypto.generateAgreementKeyPair();
-
-		// Nonce 1
-		byte[][] inputs = {
-				new byte[] {CLIENT_VERSION},
-				eKeyPair1.getPublic().getEncoded(),
-				eKeyPair2.getPublic().getEncoded()
-		};
-		SecretKey sharedSecret = crypto.deriveSharedSecret(SHARED_SECRET_LABEL,
-				eKeyPair2.getPublic(), eKeyPair1, inputs);
-		byte[] nonce1 = crypto.mac(ALICE_NONCE_LABEL, sharedSecret);
-
-		// Signature 1
-		byte[] sig1 = crypto.sign(SIGNING_LABEL, nonce1,
-				keyPair1.getPrivate().getEncoded());
-
-		// MAC 1
-		SecretKey macKey1 = crypto.deriveKey(ALICE_MAC_KEY_LABEL, sharedSecret);
-		BdfDictionary tp1 = BdfDictionary.of(new BdfEntry("fake", "fake"));
-		long time1 = clock.currentTimeMillis();
-		BdfList toMacList = BdfList.of(keyPair1.getPublic().getEncoded(),
-				eKeyPair1.getPublic().getEncoded(), tp1, time1);
-		byte[] toMac = clientHelper.toByteArray(toMacList);
-		byte[] mac1 = crypto.mac(MAC_LABEL, macKey1, toMac);
-
-		// create only relevant part of state for introducee2
-		BdfDictionary state = new BdfDictionary();
-		state.put(PUBLIC_KEY, keyPair1.getPublic().getEncoded());
-		state.put(TRANSPORT, tp1);
-		state.put(TIME, time1);
-		state.put(E_PUBLIC_KEY, eKeyPair1.getPublic().getEncoded());
-		state.put(MAC, mac1);
-		state.put(MAC_KEY, macKey1.getBytes());
-		state.put(NONCE, nonce1);
-		state.put(SIGNATURE, sig1);
-
-		// MAC and signature verification should pass
-		manager2.verifyMac(state);
-		manager2.verifySignature(state);
-
-		// replace ephemeral key pair and recalculate matching keys and nonce
-		KeyPair eKeyPair1f = crypto.generateAgreementKeyPair();
-		byte[][] fakeInputs = {
-				new byte[] {CLIENT_VERSION},
-				eKeyPair1f.getPublic().getEncoded(),
-				eKeyPair2.getPublic().getEncoded()
-		};
-		sharedSecret = crypto.deriveSharedSecret(SHARED_SECRET_LABEL,
-				eKeyPair2.getPublic(), eKeyPair1f, fakeInputs);
-		nonce1 = crypto.mac(ALICE_NONCE_LABEL, sharedSecret);
-
-		// recalculate MAC
-		macKey1 = crypto.deriveKey(ALICE_MAC_KEY_LABEL, sharedSecret);
-		toMacList = BdfList.of(keyPair1.getPublic().getEncoded(),
-				eKeyPair1f.getPublic().getEncoded(), tp1, time1);
-		toMac = clientHelper.toByteArray(toMacList);
-		mac1 = crypto.mac(MAC_LABEL, macKey1, toMac);
-
-		// update state with faked information
-		state.put(E_PUBLIC_KEY, eKeyPair1f.getPublic().getEncoded());
-		state.put(MAC, mac1);
-		state.put(MAC_KEY, macKey1.getBytes());
-		state.put(NONCE, nonce1);
-
-		// MAC verification should still pass
-		manager2.verifyMac(state);
-
-		// Signature can not be verified, because we don't have private
-		// long-term key to fake it
-		try {
-			manager2.verifySignature(state);
-			fail();
-		} catch (GeneralSecurityException e) {
-			// expected
-		}
+		testModifiedResponse(
+				m -> new AcceptMessage(m.getMessageId(), m.getGroupId(),
+						m.getTimestamp(), m.getPreviousMessageId(),
+						m.getSessionId(),
+						getRandomBytes(MAX_PUBLIC_KEY_LENGTH),
+						m.getAcceptTimestamp(), m.getTransportProperties())
+		);
 	}
 
 	private void addTransportProperties()
@@ -832,17 +959,15 @@ public class IntroductionIntegrationTest
 		TransportPropertyManager tpm0 = c0.getTransportPropertyManager();
 		TransportPropertyManager tpm1 = c1.getTransportPropertyManager();
 		TransportPropertyManager tpm2 = c2.getTransportPropertyManager();
-		TransportProperties tp = new TransportProperties(
-				Collections.singletonMap("key", "value"));
 
-		tpm0.mergeLocalProperties(TRANSPORT_ID, tp);
+		tpm0.mergeLocalProperties(TRANSPORT_ID, getTransportProperties(2));
 		sync0To1(1, true);
 		sync0To2(1, true);
 
-		tpm1.mergeLocalProperties(TRANSPORT_ID, tp);
+		tpm1.mergeLocalProperties(TRANSPORT_ID, getTransportProperties(2));
 		sync1To0(1, true);
 
-		tpm2.mergeLocalProperties(TRANSPORT_ID, tp);
+		tpm2.mergeLocalProperties(TRANSPORT_ID, getTransportProperties(2));
 		sync2To0(1, true);
 	}
 
@@ -915,27 +1040,15 @@ public class IntroductionIntegrationTest
 				long time = clock.currentTimeMillis();
 				try {
 					if (introducee == 1 && answerRequests) {
-						if (accept) {
-							introductionManager1
-									.acceptIntroduction(contactId, sessionId,
-											time);
-						} else {
-							introductionManager1
-									.declineIntroduction(contactId, sessionId,
-											time);
-						}
+						introductionManager1
+								.respondToIntroduction(contactId, sessionId,
+										time, accept);
 					} else if (introducee == 2 && answerRequests) {
-						if (accept) {
-							introductionManager2
-									.acceptIntroduction(contactId, sessionId,
-											time);
-						} else {
-							introductionManager2
-									.declineIntroduction(contactId, sessionId,
-											time);
-						}
+						introductionManager2
+								.respondToIntroduction(contactId, sessionId,
+										time, accept);
 					}
-				} catch (DbException | FormatException exception) {
+				} catch (DbException exception) {
 					eventWaiter.rethrow(exception);
 				} finally {
 					eventWaiter.resume();
@@ -945,7 +1058,6 @@ public class IntroductionIntegrationTest
 				Contact contact = ((IntroductionSucceededEvent) e).getContact();
 				eventWaiter
 						.assertFalse(contact.getId().equals(contactId0From1));
-				eventWaiter.assertTrue(contact.isActive());
 				eventWaiter.resume();
 			} else if (e instanceof IntroductionAbortedEvent) {
 				aborted = true;
@@ -981,30 +1093,87 @@ public class IntroductionIntegrationTest
 
 	}
 
-	private void decreaseOutgoingMessageCounter(ClientHelper ch, GroupId g)
+	private void replacePreviousLocalMessageId(Author author,
+			BdfDictionary d, MessageId id) throws FormatException {
+		BdfDictionary i1 = d.getDictionary(SESSION_KEY_INTRODUCEE_A);
+		BdfDictionary i2 = d.getDictionary(SESSION_KEY_INTRODUCEE_B);
+		Author a1 = clientHelper
+				.parseAndValidateAuthor(i1.getList(SESSION_KEY_AUTHOR));
+		Author a2 = clientHelper
+				.parseAndValidateAuthor(i2.getList(SESSION_KEY_AUTHOR));
+
+		if (a1.equals(author)) {
+			i1.put(SESSION_KEY_LAST_LOCAL_MESSAGE_ID, id);
+			d.put(SESSION_KEY_INTRODUCEE_A, i1);
+		} else if (a2.equals(author)) {
+			i2.put(SESSION_KEY_LAST_LOCAL_MESSAGE_ID, id);
+			d.put(SESSION_KEY_INTRODUCEE_B, i2);
+		} else {
+			throw new AssertionError();
+		}
+	}
+
+	private AbstractIntroductionMessage getMessageFor(ClientHelper ch,
+			Contact contact, MessageType type)
 			throws FormatException, DbException {
-		BdfDictionary gD = ch.getGroupMetadataAsDictionary(g);
-		LOG.warning(gD.toString());
-		BdfDictionary queue = gD.getDictionary(QUEUE_STATE_KEY);
-		queue.put("nextOut", queue.getLong("nextOut") - 1);
-		gD.put(QUEUE_STATE_KEY, queue);
-		ch.mergeGroupMetadata(g, gD);
-	}
-
-	private Entry<MessageId, BdfDictionary> getMessageFor(ClientHelper ch,
-			Contact contact, long type) throws FormatException, DbException {
-		Entry<MessageId, BdfDictionary> response = null;
-		Group g = introductionGroupFactory
-				.createIntroductionGroup(contact);
+		Group g = introductionManager0.getContactGroup(contact);
+		BdfDictionary query = BdfDictionary.of(
+				new BdfEntry(MSG_KEY_MESSAGE_TYPE, type.getValue())
+		);
 		Map<MessageId, BdfDictionary> map =
-				ch.getMessageMetadataAsDictionary(g.getId());
-		for (Entry<MessageId, BdfDictionary> entry : map.entrySet()) {
-			if (entry.getValue().getLong(TYPE) == type) {
-				response = entry;
+				ch.getMessageMetadataAsDictionary(g.getId(), query);
+		assertEquals(1, map.size());
+		MessageId id = map.entrySet().iterator().next().getKey();
+		Message m = ch.getMessage(id);
+		BdfList body = ch.getMessageAsList(id);
+		if (type == ACCEPT) {
+			//noinspection ConstantConditions
+			return c0.getMessageParser().parseAcceptMessage(m, body);
+		} else if (type == DECLINE) {
+			//noinspection ConstantConditions
+			return c0.getMessageParser().parseDeclineMessage(m, body);
+		} else if (type == AUTH) {
+			//noinspection ConstantConditions
+			return c0.getMessageParser().parseAuthMessage(m, body);
+		} else throw new AssertionError("Not implemented");
+	}
+
+	private IntroductionRequest getIntroductionRequest(
+			IntroductionManager manager, ContactId contactId)
+			throws DbException {
+		for (IntroductionMessage im : manager
+				.getIntroductionMessages(contactId)) {
+			if (im instanceof IntroductionRequest) {
+				return (IntroductionRequest) im;
 			}
 		}
-		assertTrue(response != null);
-		return response;
+		throw new AssertionError("No IntroductionRequest found");
+	}
+
+	private IntroducerSession getIntroducerSession()
+			throws DbException, FormatException {
+		Map<MessageId, BdfDictionary> dicts = c0.getClientHelper()
+				.getMessageMetadataAsDictionary(getLocalGroup().getId());
+		assertEquals(1, dicts.size());
+		BdfDictionary d = dicts.values().iterator().next();
+		return c0.getSessionParser().parseIntroducerSession(d);
+	}
+
+	private IntroduceeSession getIntroduceeSession(
+			IntroductionIntegrationTestComponent c)
+			throws DbException, FormatException {
+		Map<MessageId, BdfDictionary> dicts = c.getClientHelper()
+				.getMessageMetadataAsDictionary(getLocalGroup().getId());
+		assertEquals(1, dicts.size());
+		BdfDictionary d = dicts.values().iterator().next();
+		Group introducerGroup =
+				introductionManager2.getContactGroup(contact0From2);
+		return c.getSessionParser()
+				.parseIntroduceeSession(introducerGroup.getId(), d);
+	}
+
+	private Group getLocalGroup() {
+		return contactGroupFactory.createLocalGroup(CLIENT_ID, CLIENT_VERSION);
 	}
 
 }
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTestComponent.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTestComponent.java
index bc0ea62410713a6ae9f4a3f5034c8e10c187111b..3a90d7d148af021f0744cf0a3f67502c324e2f78 100644
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTestComponent.java
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionIntegrationTestComponent.java
@@ -59,6 +59,13 @@ interface IntroductionIntegrationTestComponent
 
 	void inject(IntroductionIntegrationTest init);
 
-	MessageSender getMessageSender();
+	void inject(MessageEncoderParserIntegrationTest init);
+	void inject(SessionEncoderParserIntegrationTest init);
+	void inject(IntroductionCryptoIntegrationTest init);
+
+	MessageEncoder getMessageEncoder();
+	MessageParser getMessageParser();
+	SessionParser getSessionParser();
+	IntroductionCrypto getIntroductionCrypto();
 
 }
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionManagerImplTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionManagerImplTest.java
deleted file mode 100644
index e445910201364626e62d3d8b74b535d81ebd7d1c..0000000000000000000000000000000000000000
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionManagerImplTest.java
+++ /dev/null
@@ -1,291 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.contact.Contact;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfEntry;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.data.MetadataParser;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.identity.Author;
-import org.briarproject.bramble.api.identity.AuthorId;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.Message;
-import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.sync.MessageStatus;
-import org.briarproject.briar.api.client.MessageTracker;
-import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.test.BriarTestCase;
-import org.jmock.Expectations;
-import org.jmock.Mockery;
-import org.jmock.lib.legacy.ClassImposteriser;
-import org.junit.Test;
-
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
-
-import static org.briarproject.bramble.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
-import static org.briarproject.bramble.test.TestUtils.getAuthor;
-import static org.briarproject.bramble.test.TestUtils.getGroup;
-import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
-import static org.briarproject.bramble.test.TestUtils.getRandomId;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_1;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID_2;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ROLE_INTRODUCER;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
-import static org.junit.Assert.assertFalse;
-
-public class IntroductionManagerImplTest extends BriarTestCase {
-
-	private final Mockery context;
-	private final IntroductionManagerImpl introductionManager;
-	private final IntroducerManager introducerManager;
-	private final IntroduceeManager introduceeManager;
-	private final DatabaseComponent db;
-	private final ClientHelper clientHelper;
-	private final MessageTracker messageTracker;
-	private final IntroductionGroupFactory introductionGroupFactory;
-	private final SessionId sessionId = new SessionId(getRandomId());
-	private final MessageId storageId = new MessageId(sessionId.getBytes());
-	private final long time = 42L;
-	private final Contact introducee1;
-	private final Contact introducee2;
-	private final Group introductionGroup1;
-	private final Group introductionGroup2;
-	private final Message message1;
-	private Transaction txn;
-
-	public IntroductionManagerImplTest() {
-		Author author1 = getAuthor();
-		AuthorId localAuthorId1 = new AuthorId(getRandomId());
-		ContactId contactId1 = new ContactId(234);
-		introducee1 =
-				new Contact(contactId1, author1, localAuthorId1, true, true);
-
-		Author author2 = getAuthor();
-		AuthorId localAuthorId2 = new AuthorId(getRandomId());
-		ContactId contactId2 = new ContactId(235);
-		introducee2 =
-				new Contact(contactId2, author2, localAuthorId2, true, true);
-
-		introductionGroup1 = getGroup(CLIENT_ID);
-		introductionGroup2 = getGroup(CLIENT_ID);
-
-		message1 = new Message(
-				new MessageId(getRandomId()),
-				introductionGroup1.getId(),
-				time,
-				getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
-		);
-
-		// mock ALL THE THINGS!!!
-		context = new Mockery();
-		context.setImposteriser(ClassImposteriser.INSTANCE);
-		introducerManager = context.mock(IntroducerManager.class);
-		introduceeManager = context.mock(IntroduceeManager.class);
-		db = context.mock(DatabaseComponent.class);
-		clientHelper = context.mock(ClientHelper.class);
-		MetadataParser metadataParser = context.mock(MetadataParser.class);
-		messageTracker = context.mock(MessageTracker.class);
-		introductionGroupFactory = context.mock(IntroductionGroupFactory.class);
-
-		introductionManager = new IntroductionManagerImpl(db, clientHelper,
-				metadataParser, messageTracker, introducerManager,
-				introduceeManager, introductionGroupFactory);
-	}
-
-	@Test
-	public void testMakeIntroduction() throws DbException, FormatException {
-		txn = new Transaction(null, false);
-
-		context.checking(new Expectations() {{
-			oneOf(db).startTransaction(false);
-			will(returnValue(txn));
-			oneOf(introducerManager)
-					.makeIntroduction(txn, introducee1, introducee2, null,
-							time);
-			// get both introduction groups
-			oneOf(introductionGroupFactory)
-					.createIntroductionGroup(introducee1);
-			will(returnValue(introductionGroup1));
-			oneOf(introductionGroupFactory)
-					.createIntroductionGroup(introducee2);
-			will(returnValue(introductionGroup2));
-			// track message for group 1
-			oneOf(messageTracker).trackMessage(txn,
-					introductionGroup1.getId(), time, true);
-			// track message for group 2
-			oneOf(messageTracker).trackMessage(txn,
-					introductionGroup2.getId(), time, true);
-			oneOf(db).commitTransaction(txn);
-			oneOf(db).endTransaction(txn);
-		}});
-
-		introductionManager
-				.makeIntroduction(introducee1, introducee2, null, time);
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testAcceptIntroduction() throws DbException, FormatException {
-		BdfDictionary state = BdfDictionary.of(
-				new BdfEntry(GROUP_ID_1, introductionGroup1.getId()),
-				new BdfEntry(GROUP_ID_2, introductionGroup2.getId())
-		);
-		txn = new Transaction(null, false);
-
-		context.checking(new Expectations() {{
-			oneOf(db).startTransaction(false);
-			will(returnValue(txn));
-			oneOf(db).getContact(txn, introducee1.getId());
-			will(returnValue(introducee1));
-			oneOf(introductionGroupFactory).createIntroductionGroup(introducee1);
-			will(returnValue(introductionGroup1));
-			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, storageId);
-			will(returnValue(state));
-			oneOf(introduceeManager).acceptIntroduction(txn, state, time);
-			// track message
-			oneOf(messageTracker).trackMessage(txn,
-					introductionGroup1.getId(), time, true);
-			oneOf(db).commitTransaction(txn);
-			oneOf(db).endTransaction(txn);
-		}});
-
-		introductionManager
-				.acceptIntroduction(introducee1.getId(), sessionId, time);
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testDeclineIntroduction() throws DbException, FormatException {
-		BdfDictionary state = BdfDictionary.of(
-				new BdfEntry(GROUP_ID_1, introductionGroup1.getId()),
-				new BdfEntry(GROUP_ID_2, introductionGroup2.getId())
-		);
-		txn = new Transaction(null, false);
-
-		context.checking(new Expectations() {{
-			oneOf(db).startTransaction(false);
-			will(returnValue(txn));
-			oneOf(db).getContact(txn, introducee1.getId());
-			will(returnValue(introducee1));
-			oneOf(introductionGroupFactory).createIntroductionGroup(introducee1);
-			will(returnValue(introductionGroup1));
-			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, storageId);
-			will(returnValue(state));
-			oneOf(introduceeManager).declineIntroduction(txn, state, time);
-			// track message
-			oneOf(messageTracker).trackMessage(txn,
-					introductionGroup1.getId(), time, true);
-			oneOf(db).commitTransaction(txn);
-			oneOf(db).endTransaction(txn);
-		}});
-
-		introductionManager
-				.declineIntroduction(introducee1.getId(), sessionId, time);
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testGetIntroductionMessages()
-			throws DbException, FormatException {
-
-		Map<MessageId, BdfDictionary> metadata = Collections.emptyMap();
-		Collection<MessageStatus> statuses = Collections.emptyList();
-		txn = new Transaction(null, false);
-
-		context.checking(new Expectations() {{
-			oneOf(db).startTransaction(true);
-			will(returnValue(txn));
-			oneOf(db).getContact(txn, introducee1.getId());
-			will(returnValue(introducee1));
-			oneOf(introductionGroupFactory).createIntroductionGroup(introducee1);
-			will(returnValue(introductionGroup1));
-			oneOf(clientHelper).getMessageMetadataAsDictionary(txn,
-					introductionGroup1.getId());
-			will(returnValue(metadata));
-			oneOf(db).getMessageStatus(txn, introducee1.getId(),
-					introductionGroup1.getId());
-			will(returnValue(statuses));
-			oneOf(db).commitTransaction(txn);
-			oneOf(db).endTransaction(txn);
-		}});
-
-		introductionManager.getIntroductionMessages(introducee1.getId());
-
-		context.assertIsSatisfied();
-	}
-
-	@Test
-	public void testIncomingRequestMessage()
-			throws DbException, FormatException {
-
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_REQUEST);
-
-		BdfDictionary state = new BdfDictionary();
-		txn = new Transaction(null, false);
-
-		context.checking(new Expectations() {{
-			oneOf(introduceeManager)
-					.initialize(txn, introductionGroup1.getId(), msg);
-			will(returnValue(state));
-			oneOf(introduceeManager)
-					.incomingMessage(txn, state, msg);
-			// track message
-			oneOf(messageTracker).trackIncomingMessage(txn, message1);
-		}});
-
-		introductionManager
-				.incomingMessage(txn, message1, new BdfList(), msg);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-	@Test
-	public void testIncomingResponseMessage()
-			throws DbException, FormatException {
-
-		BdfDictionary msg = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_RESPONSE),
-				new BdfEntry(SESSION_ID, sessionId)
-		);
-
-		BdfDictionary state = new BdfDictionary();
-		state.put(ROLE, ROLE_INTRODUCER);
-		state.put(GROUP_ID_1, introductionGroup1.getId());
-		state.put(GROUP_ID_2, introductionGroup2.getId());
-
-		txn = new Transaction(null, false);
-
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, storageId);
-			will(returnValue(state));
-			oneOf(introducerManager).incomingMessage(txn, state, msg);
-			// track message
-			oneOf(messageTracker).trackIncomingMessage(txn, message1);
-		}});
-
-		introductionManager
-				.incomingMessage(txn, message1, new BdfList(), msg);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-
-}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionValidatorTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionValidatorTest.java
index a2d481547c7c75e2e53c490f51ac4299bc10997f..4f52aa4de9f83d9df7cb4e5b735bf3557e82c524 100644
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionValidatorTest.java
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/IntroductionValidatorTest.java
@@ -1,361 +1,463 @@
 package org.briarproject.briar.introduction;
 
 import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.client.BdfMessageContext;
 import org.briarproject.bramble.api.data.BdfDictionary;
 import org.briarproject.bramble.api.data.BdfEntry;
 import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.data.MetadataEncoder;
-import org.briarproject.bramble.api.identity.Author;
-import org.briarproject.bramble.api.plugin.TransportId;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.sync.Message;
 import org.briarproject.bramble.api.sync.MessageId;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.bramble.system.SystemClock;
+import org.briarproject.bramble.test.ValidatorTestCase;
 import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.test.BriarTestCase;
-import org.jmock.Mockery;
+import org.jmock.Expectations;
 import org.junit.Test;
 
-import static org.briarproject.bramble.api.crypto.CryptoConstants.MAX_AGREEMENT_PUBLIC_KEY_BYTES;
-import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
+import javax.annotation.Nullable;
+
+import static org.briarproject.bramble.api.crypto.CryptoConstants.MAC_BYTES;
+import static org.briarproject.bramble.api.crypto.CryptoConstants.MAX_SIGNATURE_BYTES;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
-import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
-import static org.briarproject.bramble.api.properties.TransportPropertyConstants.MAX_PROPERTY_LENGTH;
-import static org.briarproject.bramble.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
-import static org.briarproject.bramble.test.TestUtils.getAuthor;
-import static org.briarproject.bramble.test.TestUtils.getClientId;
-import static org.briarproject.bramble.test.TestUtils.getGroup;
 import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
 import static org.briarproject.bramble.test.TestUtils.getRandomId;
 import static org.briarproject.bramble.util.StringUtils.getRandomString;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.ACCEPT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.E_PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC_LENGTH;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_INTRODUCTION_MESSAGE_LENGTH;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MSG;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.NAME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.PUBLIC_KEY;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TIME;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TRANSPORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ABORT;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_REQUEST;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_RESPONSE;
-import static org.junit.Assert.assertArrayEquals;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_REQUEST_MESSAGE_LENGTH;
+import static org.briarproject.briar.introduction.MessageType.ABORT;
+import static org.briarproject.briar.introduction.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction.MessageType.ACTIVATE;
+import static org.briarproject.briar.introduction.MessageType.AUTH;
+import static org.briarproject.briar.introduction.MessageType.DECLINE;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
 
-public class IntroductionValidatorTest extends BriarTestCase {
+public class IntroductionValidatorTest extends ValidatorTestCase {
+
+	private final MessageEncoder messageEncoder =
+			context.mock(MessageEncoder.class);
+	private final IntroductionValidator validator =
+			new IntroductionValidator(messageEncoder, clientHelper,
+					metadataEncoder, clock);
+
+	private final SessionId sessionId = new SessionId(getRandomId());
+	private final MessageId previousMsgId = new MessageId(getRandomId());
+	private final String text = getRandomString(MAX_REQUEST_MESSAGE_LENGTH);
+	private final BdfDictionary meta = new BdfDictionary();
+	private final long acceptTimestamp = 42;
+	private final BdfDictionary transportProperties = BdfDictionary.of(
+			new BdfEntry("transportId",  new BdfDictionary())
+	);
+	private final byte[] mac = getRandomBytes(MAC_BYTES);
+	private final byte[] signature = getRandomBytes(MAX_SIGNATURE_BYTES);
 
-	private final Mockery context = new Mockery();
-	private final Group group;
-	private final Message message;
-	private final IntroductionValidator validator;
-	private final Clock clock = new SystemClock();
+	//
+	// Introduction REQUEST
+	//
 
-	public IntroductionValidatorTest() {
-		group = getGroup(getClientId());
-		MessageId messageId = new MessageId(getRandomId());
-		long timestamp = System.currentTimeMillis();
-		byte[] raw = getRandomBytes(123);
-		message = new Message(messageId, group.getId(), timestamp, raw);
+	@Test
+	public void testAcceptsRequest() throws Exception {
+		BdfList body = BdfList.of(REQUEST.getValue(), previousMsgId.getBytes(),
+				authorList, text);
 
+		expectParseAuthor(authorList, author);
+		expectEncodeRequestMetadata();
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
 
-		ClientHelper clientHelper = context.mock(ClientHelper.class);
-		MetadataEncoder metadataEncoder = context.mock(MetadataEncoder.class);
-		validator = new IntroductionValidator(clientHelper, metadataEncoder,
-				clock);
-		context.assertIsSatisfied();
+		assertExpectedContext(messageContext, previousMsgId);
 	}
 
-	//
-	// Introduction Requests
-	//
+	@Test
+	public void testAcceptsRequestWithPreviousMsgIdNull() throws Exception {
+		BdfList body = BdfList.of(REQUEST.getValue(), null, authorList, text);
+
+		expectParseAuthor(authorList, author);
+		expectEncodeRequestMetadata();
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, null);
+	}
 
 	@Test
-	public void testValidateProperIntroductionRequest() throws Exception {
-		byte[] sessionId = getRandomId();
-		String name = getRandomString(MAX_AUTHOR_NAME_LENGTH);
-		byte[] publicKey = getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
-		String text = getRandomString(MAX_INTRODUCTION_MESSAGE_LENGTH);
+	public void testAcceptsRequestWithMessageNull() throws Exception {
+		BdfList body = BdfList.of(REQUEST.getValue(), null, authorList, null);
 
-		BdfList body = BdfList.of(TYPE_REQUEST, sessionId,
-				name, publicKey, text);
+		expectParseAuthor(authorList, author);
+		expectEncodeRequestMetadata();
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
 
-		BdfDictionary result =
-				validator.validateMessage(message, group, body).getDictionary();
+		assertExpectedContext(messageContext, null);
+	}
 
-		assertEquals(Long.valueOf(TYPE_REQUEST), result.getLong(TYPE));
-		assertEquals(sessionId, result.getRaw(SESSION_ID));
-		assertEquals(name, result.getString(NAME));
-		assertEquals(publicKey, result.getRaw(PUBLIC_KEY));
-		assertEquals(text, result.getString(MSG));
-		context.assertIsSatisfied();
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForRequest() throws Exception {
+		BdfList body = BdfList.of(REQUEST.getValue(), null, authorList);
+		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionRequestWithNoName() throws Exception {
-		BdfDictionary msg = getValidIntroductionRequest();
+	public void testRejectsTooLongBodyForRequest() throws Exception {
+		BdfList body =
+				BdfList.of(REQUEST.getValue(), null, authorList, text, null);
+		validator.validateMessage(message, group, body);
+	}
 
-		// no NAME is message
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getRaw(PUBLIC_KEY));
-		if (msg.containsKey(MSG)) body.add(msg.getString(MSG));
+	@Test(expected = FormatException.class)
+	public void testRejectsRawMessageForRequest() throws Exception {
+		BdfList body =
+				BdfList.of(REQUEST.getValue(), null, authorList, getRandomId());
+		expectParseAuthor(authorList, author);
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsStringMessageIdForRequest() throws Exception {
+		BdfList body =
+				BdfList.of(REQUEST.getValue(), "NoMessageId", authorList, null);
 		validator.validateMessage(message, group, body);
 	}
 
+	//
+	// Introduction ACCEPT
+	//
+
+	@Test
+	public void testAcceptsAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), getRandomBytes(MAX_PUBLIC_KEY_LENGTH),
+				acceptTimestamp, transportProperties);
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).parseAndValidateTransportPropertiesMap(
+					transportProperties);
+		}});
+		expectEncodeMetadata(ACCEPT);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, previousMsgId);
+	}
+
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionRequestWithLongName() throws Exception {
-		// too long NAME in message
-		BdfDictionary msg = getValidIntroductionRequest();
-		msg.put(NAME, msg.get(NAME) + "x");
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getString(NAME), msg.getRaw(PUBLIC_KEY));
-		if (msg.containsKey(MSG)) body.add(msg.getString(MSG));
+	public void testRejectsTooShortBodyForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(),
+				getRandomBytes(MAX_PUBLIC_KEY_LENGTH), acceptTimestamp);
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongBodyForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), getRandomBytes(MAX_PUBLIC_KEY_LENGTH),
+				acceptTimestamp, transportProperties, null);
 		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionRequestWithWrongType()
-			throws Exception {
-		// wrong message type
-		BdfDictionary msg = getValidIntroductionRequest();
-		msg.put(TYPE, 324234);
+	public void testRejectsInvalidSessionIdForAccept() throws Exception {
+		BdfList body =
+				BdfList.of(ACCEPT.getValue(), null, previousMsgId.getBytes(),
+						getRandomBytes(MAX_PUBLIC_KEY_LENGTH), acceptTimestamp,
+						transportProperties);
+		validator.validateMessage(message, group, body);
+	}
 
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getString(NAME), msg.getRaw(PUBLIC_KEY));
-		if (msg.containsKey(MSG)) body.add(msg.getString(MSG));
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidPreviousMsgIdForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(), 1,
+				getRandomBytes(MAX_PUBLIC_KEY_LENGTH), acceptTimestamp,
+				transportProperties);
 		validator.validateMessage(message, group, body);
 	}
 
-	private BdfDictionary getValidIntroductionRequest() throws Exception {
-		byte[] sessionId = getRandomId();
-		Author author = getAuthor();
-		String text = getRandomString(MAX_MESSAGE_BODY_LENGTH);
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongPublicKeyForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(),
+				getRandomBytes(MAX_PUBLIC_KEY_LENGTH + 1), acceptTimestamp,
+				transportProperties);
+		validator.validateMessage(message, group, body);
+	}
 
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_REQUEST);
-		msg.put(SESSION_ID, sessionId);
-		msg.put(NAME, author.getName());
-		msg.put(PUBLIC_KEY, author.getPublicKey());
-		msg.put(MSG, text);
+	@Test(expected = FormatException.class)
+	public void testRejectsNegativeTimestampForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), getRandomBytes(MAX_PUBLIC_KEY_LENGTH),
+				-1, transportProperties);
+		validator.validateMessage(message, group, body);
+	}
 
-		return msg;
+	@Test(expected = FormatException.class)
+	public void testRejectsEmptyTransportPropertiesForAccept()
+			throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(),
+				getRandomBytes(MAX_PUBLIC_KEY_LENGTH + 1), acceptTimestamp,
+				new BdfDictionary());
+		validator.validateMessage(message, group, body);
 	}
 
 	//
-	// Introduction Responses
+	// Introduction DECLINE
 	//
 
 	@Test
-	public void testValidateIntroductionAcceptResponse() throws Exception {
-		byte[] groupId = getRandomId();
-		byte[] sessionId = getRandomId();
-		long time = clock.currentTimeMillis();
-		byte[] publicKey = getRandomBytes(MAX_AGREEMENT_PUBLIC_KEY_BYTES);
-		String transportId =
-				getRandomString(TransportId.MAX_TRANSPORT_ID_LENGTH);
-		BdfDictionary tProps = BdfDictionary.of(
-				new BdfEntry(getRandomString(MAX_PROPERTY_LENGTH),
-						getRandomString(MAX_PROPERTY_LENGTH))
-		);
-		BdfDictionary tp = BdfDictionary.of(
-				new BdfEntry(transportId, tProps)
-		);
-
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_RESPONSE);
-		msg.put(GROUP_ID, groupId);
-		msg.put(SESSION_ID, sessionId);
-		msg.put(ACCEPT, true);
-		msg.put(TIME, time);
-		msg.put(E_PUBLIC_KEY, publicKey);
-		msg.put(TRANSPORT, tp);
-
-		BdfList body = BdfList.of(TYPE_RESPONSE, msg.getRaw(SESSION_ID),
-				msg.getBoolean(ACCEPT), msg.getLong(TIME),
-				msg.getRaw(E_PUBLIC_KEY), msg.getDictionary(TRANSPORT));
-
-		BdfDictionary result =
-				validator.validateMessage(message, group, body).getDictionary();
-
-		assertEquals(Long.valueOf(TYPE_RESPONSE), result.getLong(TYPE));
-		assertEquals(sessionId, result.getRaw(SESSION_ID));
-		assertEquals(true, result.getBoolean(ACCEPT));
-		assertEquals(publicKey, result.getRaw(E_PUBLIC_KEY));
-		assertEquals(tp, result.getDictionary(TRANSPORT));
-		context.assertIsSatisfied();
+	public void testAcceptsDecline() throws Exception {
+		BdfList body = BdfList.of(DECLINE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes());
+
+		expectEncodeMetadata(DECLINE);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, previousMsgId);
 	}
 
-	@Test
-	public void testValidateIntroductionDeclineResponse() throws Exception {
-		BdfDictionary msg = getValidIntroductionResponse(false);
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getBoolean(ACCEPT));
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForDecline() throws Exception {
+		BdfList body = BdfList.of(DECLINE.getValue(), sessionId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
 
-		BdfDictionary result = validator.validateMessage(message, group, body)
-				.getDictionary();
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongBodyForDecline() throws Exception {
+		BdfList body = BdfList.of(DECLINE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), null);
+		validator.validateMessage(message, group, body);
+	}
 
-		assertFalse(result.getBoolean(ACCEPT));
-		context.assertIsSatisfied();
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidSessionIdForDecline() throws Exception {
+		BdfList body =
+				BdfList.of(DECLINE.getValue(), null, previousMsgId.getBytes());
+		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionResponseWithoutAccept()
-			throws Exception {
-		BdfDictionary msg = getValidIntroductionResponse(false);
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+	public void testRejectsInvalidPreviousMsgIdForDecline() throws Exception {
+		BdfList body = BdfList.of(DECLINE.getValue(), sessionId.getBytes(), 1);
+		validator.validateMessage(message, group, body);
+	}
+
+	//
+	// Introduction AUTH
+	//
+
+	@Test
+	public void testAcceptsAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac, signature);
+
+		expectEncodeMetadata(AUTH);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
 
+		assertExpectedContext(messageContext, previousMsgId);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac);
 		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionResponseWithBrokenTp()
-			throws Exception {
-		BdfDictionary msg = getValidIntroductionResponse(true);
-		BdfDictionary tp = msg.getDictionary(TRANSPORT);
-		tp.put(
-				getRandomString(TransportId.MAX_TRANSPORT_ID_LENGTH), "X");
-		msg.put(TRANSPORT, tp);
+	public void testRejectsTooLongBodyForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac, signature, null);
+		validator.validateMessage(message, group, body);
+	}
 
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getBoolean(ACCEPT), msg.getLong(TIME),
-				msg.getRaw(E_PUBLIC_KEY), msg.getDictionary(TRANSPORT));
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidPreviousMsgIdForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				1, getRandomBytes(MAC_BYTES),
+				signature);
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsPreviousMsgIdNullForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(), null,
+				getRandomBytes(MAC_BYTES), signature);
 		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionResponseWithoutPublicKey()
-			throws Exception {
-		BdfDictionary msg = getValidIntroductionResponse(true);
+	public void testRejectsTooShortMacForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), getRandomBytes(MAC_BYTES - 1),
+				signature);
+		validator.validateMessage(message, group, body);
+	}
 
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getBoolean(ACCEPT), msg.getLong(TIME),
-				msg.getDictionary(TRANSPORT));
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongMacForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(),
+				getRandomBytes(MAC_BYTES + 1), signature);
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidMacForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), null, signature);
 		validator.validateMessage(message, group, body);
 	}
 
-	private BdfDictionary getValidIntroductionResponse(boolean accept)
-			throws Exception {
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortSignatureForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac, getRandomBytes(0));
+		validator.validateMessage(message, group, body);
+	}
 
-		byte[] groupId = getRandomId();
-		byte[] sessionId = getRandomId();
-		long time = clock.currentTimeMillis();
-		byte[] publicKey = getRandomBytes(MAX_AGREEMENT_PUBLIC_KEY_BYTES);
-		String transportId =
-				getRandomString(TransportId.MAX_TRANSPORT_ID_LENGTH);
-		BdfDictionary tProps = BdfDictionary.of(
-				new BdfEntry(getRandomString(MAX_PROPERTY_LENGTH),
-						getRandomString(MAX_PROPERTY_LENGTH))
-		);
-		BdfDictionary tp = BdfDictionary.of(
-				new BdfEntry(transportId, tProps)
-		);
-
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_RESPONSE);
-		msg.put(GROUP_ID, groupId);
-		msg.put(SESSION_ID, sessionId);
-		msg.put(ACCEPT, accept);
-		if (accept) {
-			msg.put(TIME, time);
-			msg.put(E_PUBLIC_KEY, publicKey);
-			msg.put(TRANSPORT, tp);
-		}
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongSignatureForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac,
+				getRandomBytes(MAX_SIGNATURE_BYTES + 1));
+		validator.validateMessage(message, group, body);
+	}
 
-		return msg;
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidSignatureForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac, null);
+		validator.validateMessage(message, group, body);
 	}
 
 	//
-	// Introduction ACK
+	// Introduction ACTIVATE
 	//
 
 	@Test
-	public void testValidateProperIntroductionAck() throws Exception {
-		byte[] sessionId = getRandomId();
-		byte[] mac = getRandomBytes(MAC_LENGTH);
-		byte[] sig = getRandomBytes(MAX_SIGNATURE_LENGTH);
-		BdfList body = BdfList.of(TYPE_ACK, sessionId, mac, sig);
+	public void testAcceptsActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac);
 
-		BdfDictionary result =
-				validator.validateMessage(message, group, body).getDictionary();
+		expectEncodeMetadata(ACTIVATE);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
 
-		assertEquals(Long.valueOf(TYPE_ACK), result.getLong(TYPE));
-		assertArrayEquals(sessionId, result.getRaw(SESSION_ID));
-		assertArrayEquals(mac, result.getRaw(MAC));
-		assertArrayEquals(sig, result.getRaw(SIGNATURE));
-		context.assertIsSatisfied();
+		assertExpectedContext(messageContext, previousMsgId);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateTooLongIntroductionAck() throws Exception {
-		BdfDictionary msg = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_ACK),
-				new BdfEntry(SESSION_ID, getRandomId()),
-				new BdfEntry("garbage", getRandomString(255))
-		);
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getString("garbage"));
+	public void testRejectsTooShortBodyForActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongBodyForActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac, null);
 		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateIntroductionAckWithLongSessionId()
-			throws Exception {
-		BdfDictionary msg = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_ACK),
-				new BdfEntry(SESSION_ID, new byte[SessionId.LENGTH + 1])
-		);
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+	public void testRejectsInvalidSessionIdForActivate() throws Exception {
+		BdfList body =
+				BdfList.of(ACTIVATE.getValue(), null, previousMsgId.getBytes(),
+						mac);
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidPreviousMsgIdForActivate() throws Exception {
+		BdfList body =
+				BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(), 1, mac);
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsPreviousMsgIdNullForActivate() throws Exception {
+		BdfList body =
+				BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(), null,
+						mac);
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidMacForActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), getRandomBytes(MAC_BYTES - 1));
 		validator.validateMessage(message, group, body);
 	}
 
 	//
-	// Introduction Abort
+	// Introduction ABORT
 	//
 
 	@Test
-	public void testValidateProperIntroductionAbort() throws Exception {
-		byte[] sessionId = getRandomId();
+	public void testAcceptsAbort() throws Exception {
+		BdfList body = BdfList.of(ABORT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes());
 
-		BdfDictionary msg = new BdfDictionary();
-		msg.put(TYPE, TYPE_ABORT);
-		msg.put(SESSION_ID, sessionId);
+		expectEncodeMetadata(ABORT);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
 
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID));
+		assertExpectedContext(messageContext, previousMsgId);
+	}
 
-		BdfDictionary result =
-				validator.validateMessage(message, group, body).getDictionary();
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForAbort() throws Exception {
+		BdfList body = BdfList.of(ABORT.getValue(), sessionId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
 
-		assertEquals(Long.valueOf(TYPE_ABORT), result.getLong(TYPE));
-		assertEquals(sessionId, result.getRaw(SESSION_ID));
-		context.assertIsSatisfied();
+	@Test(expected = FormatException.class)
+	public void testRejectsTooLongBodyForAbort() throws Exception {
+		BdfList body = BdfList.of(ABORT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), null);
+		validator.validateMessage(message, group, body);
 	}
 
 	@Test(expected = FormatException.class)
-	public void testValidateTooLongIntroductionAbort() throws Exception {
-		BdfDictionary msg = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_ABORT),
-				new BdfEntry(SESSION_ID, getRandomId()),
-				new BdfEntry("garbage", getRandomString(255))
-		);
-		BdfList body = BdfList.of(msg.getLong(TYPE), msg.getRaw(SESSION_ID),
-				msg.getString("garbage"));
+	public void testRejectsInvalidSessionIdForAbort() throws Exception {
+		BdfList body =
+				BdfList.of(ABORT.getValue(), null, previousMsgId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
 
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidPreviousMsgIdForAbort() throws Exception {
+		BdfList body = BdfList.of(ABORT.getValue(), sessionId.getBytes(), 1);
 		validator.validateMessage(message, group, body);
 	}
 
+	//
+	// Introduction Helper Methods
+	//
+
+	private void expectEncodeRequestMetadata() {
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder).encodeRequestMetadata(message.getTimestamp());
+			will(returnValue(meta));
+		}});
+	}
+
+	private void expectEncodeMetadata(MessageType type) {
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder)
+					.encodeMetadata(type, sessionId, message.getTimestamp(),
+							false, false, false);
+			will(returnValue(meta));
+		}});
+	}
+
+	private void assertExpectedContext(BdfMessageContext c,
+			@Nullable MessageId dependency) {
+		assertEquals(meta, c.getDictionary());
+		if (dependency == null) {
+			assertEquals(0, c.getDependencies().size());
+		} else {
+			assertEquals(dependency, c.getDependencies().iterator().next());
+		}
+	}
+
 }
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/MessageEncoderParserIntegrationTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/MessageEncoderParserIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7b15b6ab43e665f52c99af222f833298a758c161
--- /dev/null
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/MessageEncoderParserIntegrationTest.java
@@ -0,0 +1,246 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.MetadataEncoder;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorFactory;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageFactory;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.test.BrambleTestCase;
+import org.briarproject.briar.api.client.SessionId;
+import org.junit.Test;
+
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import static org.briarproject.bramble.api.crypto.CryptoConstants.MAC_BYTES;
+import static org.briarproject.bramble.api.crypto.CryptoConstants.MAX_SIGNATURE_BYTES;
+import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
+import static org.briarproject.bramble.test.TestUtils.getRandomId;
+import static org.briarproject.bramble.test.TestUtils.getTransportPropertiesMap;
+import static org.briarproject.bramble.util.StringUtils.getRandomString;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_REQUEST_MESSAGE_LENGTH;
+import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
+import static org.briarproject.briar.introduction.MessageType.ABORT;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
+import static org.briarproject.briar.test.BriarTestUtils.getRealAuthor;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class MessageEncoderParserIntegrationTest extends BrambleTestCase {
+
+	@Inject
+	ClientHelper clientHelper;
+	@Inject
+	MessageFactory messageFactory;
+	@Inject
+	MetadataEncoder metadataEncoder;
+	@Inject
+	AuthorFactory authorFactory;
+	@Inject
+	Clock clock;
+
+	private final MessageEncoder messageEncoder;
+	private final MessageParser messageParser;
+	private final IntroductionValidator validator;
+
+	private final GroupId groupId = new GroupId(getRandomId());
+	private final Group group = new Group(groupId, CLIENT_ID, getRandomId());
+	private final long timestamp = 42L;
+	private final SessionId sessionId = new SessionId(getRandomId());
+	private final MessageId previousMsgId = new MessageId(getRandomId());
+	private final Author author;
+	private final String text = getRandomString(MAX_REQUEST_MESSAGE_LENGTH);
+	private final byte[] ephemeralPublicKey =
+			getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+	private final byte[] mac = getRandomBytes(MAC_BYTES);
+	private final byte[] signature = getRandomBytes(MAX_SIGNATURE_BYTES);
+
+	public MessageEncoderParserIntegrationTest() {
+		IntroductionIntegrationTestComponent component =
+				DaggerIntroductionIntegrationTestComponent.builder().build();
+		component.inject(this);
+
+		messageEncoder = new MessageEncoderImpl(clientHelper, messageFactory);
+		messageParser = new MessageParserImpl(clientHelper);
+		validator = new IntroductionValidator(messageEncoder, clientHelper,
+				metadataEncoder, clock);
+		author = getRealAuthor(authorFactory);
+	}
+
+	@Test
+	public void testRequestMessageMetadata() throws FormatException {
+		BdfDictionary d = messageEncoder
+				.encodeRequestMetadata(timestamp);
+		MessageMetadata meta = messageParser.parseMetadata(d);
+
+		assertEquals(REQUEST, meta.getMessageType());
+		assertNull(meta.getSessionId());
+		assertEquals(timestamp, meta.getTimestamp());
+		assertFalse(meta.isLocal());
+		assertFalse(meta.isRead());
+		assertFalse(meta.isVisibleInConversation());
+		assertFalse(meta.isAvailableToAnswer());
+	}
+
+	@Test
+	public void testMessageMetadata() throws FormatException {
+		BdfDictionary d = messageEncoder
+				.encodeMetadata(ABORT, sessionId, timestamp, false, true,
+						false);
+		MessageMetadata meta = messageParser.parseMetadata(d);
+
+		assertEquals(ABORT, meta.getMessageType());
+		assertEquals(sessionId, meta.getSessionId());
+		assertEquals(timestamp, meta.getTimestamp());
+		assertFalse(meta.isLocal());
+		assertTrue(meta.isRead());
+		assertFalse(meta.isVisibleInConversation());
+		assertFalse(meta.isAvailableToAnswer());
+	}
+
+	@Test
+	public void testRequestMessage() throws FormatException {
+		Message m = messageEncoder
+				.encodeRequestMessage(groupId, timestamp, previousMsgId, author,
+						text);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		RequestMessage rm =
+				messageParser.parseRequestMessage(m, clientHelper.toList(m));
+
+		assertEquals(m.getId(), rm.getMessageId());
+		assertEquals(m.getGroupId(), rm.getGroupId());
+		assertEquals(m.getTimestamp(), rm.getTimestamp());
+		assertEquals(previousMsgId, rm.getPreviousMessageId());
+		assertEquals(author, rm.getAuthor());
+		assertEquals(text, rm.getMessage());
+	}
+
+	@Test
+	public void testRequestMessageWithPreviousMsgNull() throws FormatException {
+		Message m = messageEncoder
+				.encodeRequestMessage(groupId, timestamp, null, author, text);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		RequestMessage rm =
+				messageParser.parseRequestMessage(m, clientHelper.toList(m));
+
+		assertNull(rm.getPreviousMessageId());
+	}
+
+	@Test
+	public void testRequestMessageWithMsgNull() throws FormatException {
+		Message m = messageEncoder
+				.encodeRequestMessage(groupId, timestamp, previousMsgId, author,
+						null);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		RequestMessage rm =
+				messageParser.parseRequestMessage(m, clientHelper.toList(m));
+
+		assertNull(rm.getMessage());
+	}
+
+	@Test
+	public void testAcceptMessage() throws Exception {
+		Map<TransportId, TransportProperties> transportProperties =
+				getTransportPropertiesMap(2);
+
+		long acceptTimestamp = 1337L;
+		Message m = messageEncoder
+				.encodeAcceptMessage(groupId, timestamp, previousMsgId,
+						sessionId, ephemeralPublicKey, acceptTimestamp,
+						transportProperties);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		AcceptMessage am =
+				messageParser.parseAcceptMessage(m, clientHelper.toList(m));
+
+		assertEquals(m.getId(), am.getMessageId());
+		assertEquals(m.getGroupId(), am.getGroupId());
+		assertEquals(m.getTimestamp(), am.getTimestamp());
+		assertEquals(previousMsgId, am.getPreviousMessageId());
+		assertEquals(sessionId, am.getSessionId());
+		assertArrayEquals(ephemeralPublicKey, am.getEphemeralPublicKey());
+		assertEquals(acceptTimestamp, am.getAcceptTimestamp());
+		assertEquals(transportProperties, am.getTransportProperties());
+	}
+
+	@Test
+	public void testDeclineMessage() throws Exception {
+		Message m = messageEncoder
+				.encodeDeclineMessage(groupId, timestamp, previousMsgId,
+						sessionId);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		DeclineMessage dm =
+				messageParser.parseDeclineMessage(m, clientHelper.toList(m));
+
+		assertEquals(m.getId(), dm.getMessageId());
+		assertEquals(m.getGroupId(), dm.getGroupId());
+		assertEquals(m.getTimestamp(), dm.getTimestamp());
+		assertEquals(previousMsgId, dm.getPreviousMessageId());
+		assertEquals(sessionId, dm.getSessionId());
+	}
+
+	@Test
+	public void testAuthMessage() throws Exception {
+		Message m = messageEncoder
+				.encodeAuthMessage(groupId, timestamp, previousMsgId,
+						sessionId, mac, signature);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		AuthMessage am =
+				messageParser.parseAuthMessage(m, clientHelper.toList(m));
+
+		assertEquals(m.getId(), am.getMessageId());
+		assertEquals(m.getGroupId(), am.getGroupId());
+		assertEquals(m.getTimestamp(), am.getTimestamp());
+		assertEquals(previousMsgId, am.getPreviousMessageId());
+		assertEquals(sessionId, am.getSessionId());
+		assertArrayEquals(mac, am.getMac());
+		assertArrayEquals(signature, am.getSignature());
+	}
+
+	@Test
+	public void testActivateMessage() throws Exception {
+		Message m = messageEncoder
+				.encodeActivateMessage(groupId, timestamp, previousMsgId,
+						sessionId, mac);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		ActivateMessage am =
+				messageParser.parseActivateMessage(m, clientHelper.toList(m));
+
+		assertEquals(m.getId(), am.getMessageId());
+		assertEquals(m.getGroupId(), am.getGroupId());
+		assertEquals(m.getTimestamp(), am.getTimestamp());
+		assertEquals(previousMsgId, am.getPreviousMessageId());
+		assertEquals(sessionId, am.getSessionId());
+		assertArrayEquals(mac, am.getMac());
+	}
+
+	@Test
+	public void testAbortMessage() throws Exception {
+		Message m = messageEncoder
+				.encodeAbortMessage(groupId, timestamp, previousMsgId,
+						sessionId);
+		validator.validateMessage(m, group, clientHelper.toList(m));
+		AbortMessage am =
+				messageParser.parseAbortMessage(m, clientHelper.toList(m));
+
+		assertEquals(m.getId(), am.getMessageId());
+		assertEquals(m.getGroupId(), am.getGroupId());
+		assertEquals(m.getTimestamp(), am.getTimestamp());
+		assertEquals(previousMsgId, am.getPreviousMessageId());
+		assertEquals(sessionId, am.getSessionId());
+	}
+
+}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/MessageEncoderTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/MessageEncoderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..56fe04bdb3219547caa2e01a80b1fbb0ccc94216
--- /dev/null
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/MessageEncoderTest.java
@@ -0,0 +1,59 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageFactory;
+import org.briarproject.bramble.test.BrambleMockTestCase;
+import org.jmock.Expectations;
+import org.junit.Test;
+
+import static org.briarproject.bramble.test.TestUtils.getAuthor;
+import static org.briarproject.bramble.test.TestUtils.getMessage;
+import static org.briarproject.bramble.test.TestUtils.getRandomId;
+import static org.briarproject.bramble.util.StringUtils.getRandomString;
+import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_REQUEST_MESSAGE_LENGTH;
+import static org.briarproject.briar.introduction.MessageType.REQUEST;
+
+public class MessageEncoderTest extends BrambleMockTestCase {
+
+	private final ClientHelper clientHelper = context.mock(ClientHelper.class);
+	private final MessageFactory messageFactory =
+			context.mock(MessageFactory.class);
+	private final MessageEncoder messageEncoder =
+			new MessageEncoderImpl(clientHelper, messageFactory);
+
+	private final GroupId groupId = new GroupId(getRandomId());
+	private final Message message = getMessage(groupId);
+	private final long timestamp = message.getTimestamp();
+	private final byte[] body = message.getRaw();
+	private final Author author = getAuthor();
+	private final BdfList authorList = new BdfList();
+	private final String text = getRandomString(MAX_REQUEST_MESSAGE_LENGTH);
+
+	@Test
+	public void testEncodeRequestMessage() throws FormatException {
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).toList(author);
+			will(returnValue(authorList));
+		}});
+		expectCreateMessage(
+				BdfList.of(REQUEST.getValue(), null, authorList, text));
+
+		messageEncoder
+				.encodeRequestMessage(groupId, timestamp, null, author, text);
+	}
+
+	private void expectCreateMessage(BdfList bodyList) throws FormatException {
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).toByteArray(bodyList);
+			will(returnValue(body));
+			oneOf(messageFactory).createMessage(groupId, timestamp, body);
+			will(returnValue(message));
+		}});
+	}
+
+}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/MessageSenderTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/MessageSenderTest.java
deleted file mode 100644
index 1f922d70644fef119fa0b506dd25707ffa46b9d4..0000000000000000000000000000000000000000
--- a/briar-core/src/test/java/org/briarproject/briar/introduction/MessageSenderTest.java
+++ /dev/null
@@ -1,98 +0,0 @@
-package org.briarproject.briar.introduction;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.client.ClientHelper;
-import org.briarproject.bramble.api.data.BdfDictionary;
-import org.briarproject.bramble.api.data.BdfEntry;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.data.MetadataEncoder;
-import org.briarproject.bramble.api.db.DatabaseComponent;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.db.Metadata;
-import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.sync.Group;
-import org.briarproject.bramble.api.system.Clock;
-import org.briarproject.briar.api.client.MessageQueueManager;
-import org.briarproject.briar.api.client.SessionId;
-import org.briarproject.briar.test.BriarTestCase;
-import org.jmock.Expectations;
-import org.jmock.Mockery;
-import org.junit.Test;
-
-import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
-import static org.briarproject.bramble.test.TestUtils.getClientId;
-import static org.briarproject.bramble.test.TestUtils.getGroup;
-import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
-import static org.briarproject.bramble.test.TestUtils.getRandomId;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.GROUP_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.MAC;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SESSION_ID;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.SIGNATURE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE;
-import static org.briarproject.briar.api.introduction.IntroductionConstants.TYPE_ACK;
-import static org.junit.Assert.assertFalse;
-
-public class MessageSenderTest extends BriarTestCase {
-
-	private final Mockery context;
-	private final MessageSender messageSender;
-	private final DatabaseComponent db;
-	private final ClientHelper clientHelper;
-	private final MetadataEncoder metadataEncoder;
-	private final MessageQueueManager messageQueueManager;
-	private final Clock clock;
-
-	public MessageSenderTest() {
-		context = new Mockery();
-		db = context.mock(DatabaseComponent.class);
-		clientHelper = context.mock(ClientHelper.class);
-		metadataEncoder =
-				context.mock(MetadataEncoder.class);
-		messageQueueManager =
-				context.mock(MessageQueueManager.class);
-		clock = context.mock(Clock.class);
-
-		messageSender = new MessageSender(db, clientHelper, clock,
-				metadataEncoder, messageQueueManager);
-	}
-
-	@Test
-	public void testSendMessage() throws DbException, FormatException {
-		Transaction txn = new Transaction(null, false);
-		Group privateGroup = getGroup(getClientId());
-		SessionId sessionId = new SessionId(getRandomId());
-		byte[] mac = getRandomBytes(42);
-		byte[] sig = getRandomBytes(MAX_SIGNATURE_LENGTH);
-		long time = 42L;
-		BdfDictionary msg = BdfDictionary.of(
-				new BdfEntry(TYPE, TYPE_ACK),
-				new BdfEntry(GROUP_ID, privateGroup.getId()),
-				new BdfEntry(SESSION_ID, sessionId),
-				new BdfEntry(MAC, mac),
-				new BdfEntry(SIGNATURE, sig)
-		);
-		BdfList bodyList =
-				BdfList.of(TYPE_ACK, sessionId.getBytes(), mac, sig);
-		byte[] body = getRandomBytes(8);
-		Metadata metadata = new Metadata();
-
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).toByteArray(bodyList);
-			will(returnValue(body));
-			oneOf(db).getGroup(txn, privateGroup.getId());
-			will(returnValue(privateGroup));
-			oneOf(metadataEncoder).encode(msg);
-			will(returnValue(metadata));
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(time));
-			oneOf(messageQueueManager)
-					.sendMessage(txn, privateGroup, time, body, metadata);
-		}});
-
-		messageSender.sendMessage(txn, msg);
-
-		context.assertIsSatisfied();
-		assertFalse(txn.isCommitted());
-	}
-
-}
diff --git a/briar-core/src/test/java/org/briarproject/briar/introduction/SessionEncoderParserIntegrationTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction/SessionEncoderParserIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9ddb006323c4f5f28c9c36a59a80c53b9d1015fb
--- /dev/null
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction/SessionEncoderParserIntegrationTest.java
@@ -0,0 +1,328 @@
+package org.briarproject.briar.introduction;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorFactory;
+import org.briarproject.bramble.api.plugin.TransportId;
+import org.briarproject.bramble.api.properties.TransportProperties;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.transport.KeySetId;
+import org.briarproject.bramble.test.BrambleTestCase;
+import org.briarproject.briar.api.client.SessionId;
+import org.briarproject.briar.introduction.IntroducerSession.Introducee;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
+import static org.briarproject.bramble.test.TestUtils.getRandomId;
+import static org.briarproject.bramble.test.TestUtils.getTransportId;
+import static org.briarproject.bramble.test.TestUtils.getTransportPropertiesMap;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCEE;
+import static org.briarproject.briar.api.introduction.Role.INTRODUCER;
+import static org.briarproject.briar.introduction.IntroduceeSession.Local;
+import static org.briarproject.briar.introduction.IntroduceeSession.Remote;
+import static org.briarproject.briar.introduction.IntroduceeState.LOCAL_ACCEPTED;
+import static org.briarproject.briar.introduction.IntroducerState.AWAIT_AUTHS;
+import static org.briarproject.briar.introduction.IntroductionConstants.SESSION_KEY_ROLE;
+import static org.briarproject.briar.test.BriarTestUtils.getRealAuthor;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class SessionEncoderParserIntegrationTest extends BrambleTestCase {
+
+	@Inject
+	ClientHelper clientHelper;
+	@Inject
+	AuthorFactory authorFactory;
+
+	private final SessionEncoder sessionEncoder;
+	private final SessionParser sessionParser;
+
+	private final GroupId groupId1 = new GroupId(getRandomId());
+	private final GroupId groupId2 = new GroupId(getRandomId());
+	private final SessionId sessionId = new SessionId(getRandomId());
+	private final long requestTimestamp = 42;
+	private final long localTimestamp = 1337;
+	private final long localTimestamp2 = 1338;
+	private final long acceptTimestamp = 123456;
+	private final long remoteAcceptTimestamp = 1234567;
+	private final MessageId lastLocalMessageId = new MessageId(getRandomId());
+	private final MessageId lastLocalMessageId2 = new MessageId(getRandomId());
+	private final MessageId lastRemoteMessageId = new MessageId(getRandomId());
+	private final MessageId lastRemoteMessageId2 = new MessageId(getRandomId());
+	private final Author author1;
+	private final Author author2;
+	private final byte[] ephemeralPublicKey =
+			getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+	private final byte[] ephemeralPrivateKey =
+			getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+	private final byte[] masterKey = getRandomBytes(SecretKey.LENGTH);
+	private final byte[] remoteEphemeralPublicKey =
+			getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+	private final Map<TransportId, TransportProperties> transportProperties =
+			getTransportPropertiesMap(3);
+	private final Map<TransportId, TransportProperties>
+			remoteTransportProperties = getTransportPropertiesMap(3);
+	private final Map<TransportId, KeySetId> transportKeys = new HashMap<>();
+	private final byte[] localMacKey = getRandomBytes(SecretKey.LENGTH);
+	private final byte[] remoteMacKey = getRandomBytes(SecretKey.LENGTH);
+
+	public SessionEncoderParserIntegrationTest() {
+		IntroductionIntegrationTestComponent component =
+				DaggerIntroductionIntegrationTestComponent.builder().build();
+		component.inject(this);
+
+		sessionEncoder = new SessionEncoderImpl(clientHelper);
+		sessionParser = new SessionParserImpl(clientHelper);
+		author1 = getRealAuthor(authorFactory);
+		author2 = getRealAuthor(authorFactory);
+		transportKeys.put(getTransportId(), new KeySetId(1));
+		transportKeys.put(getTransportId(), new KeySetId(2));
+		transportKeys.put(getTransportId(), new KeySetId(3));
+	}
+
+	@Test
+	public void testIntroducerSession() throws FormatException {
+		IntroducerSession s1 = getIntroducerSession();
+
+		BdfDictionary d = sessionEncoder.encodeIntroducerSession(s1);
+		IntroducerSession s2 = sessionParser.parseIntroducerSession(d);
+
+		assertEquals(INTRODUCER, s1.getRole());
+		assertEquals(s1.getRole(), s2.getRole());
+		assertEquals(sessionId, s1.getSessionId());
+		assertEquals(s1.getSessionId(), s2.getSessionId());
+		assertEquals(AWAIT_AUTHS, s1.getState());
+		assertEquals(s1.getState(), s2.getState());
+		assertIntroduceeEquals(s1.getIntroduceeA(), s2.getIntroduceeA());
+		assertIntroduceeEquals(s1.getIntroduceeB(), s2.getIntroduceeB());
+	}
+
+	@Test
+	public void testIntroducerSessionWithNulls() throws FormatException {
+		Introducee introducee1 =
+				new Introducee(sessionId, groupId1, author1, localTimestamp,
+						null, null);
+		Introducee introducee2 =
+				new Introducee(sessionId, groupId2, author2, localTimestamp2,
+						null, null);
+		IntroducerSession s1 = new IntroducerSession(sessionId,
+				AWAIT_AUTHS, requestTimestamp, introducee1,
+				introducee2);
+
+		BdfDictionary d = sessionEncoder.encodeIntroducerSession(s1);
+		IntroducerSession s2 = sessionParser.parseIntroducerSession(d);
+
+		assertNull(s1.getIntroduceeA().lastLocalMessageId);
+		assertEquals(s1.getIntroduceeA().lastLocalMessageId,
+				s2.getIntroduceeA().lastLocalMessageId);
+		assertNull(s1.getIntroduceeA().lastRemoteMessageId);
+		assertEquals(s1.getIntroduceeA().lastRemoteMessageId,
+				s2.getIntroduceeA().lastRemoteMessageId);
+
+		assertNull(s1.getIntroduceeB().lastLocalMessageId);
+		assertEquals(s1.getIntroduceeB().lastLocalMessageId,
+				s2.getIntroduceeB().lastLocalMessageId);
+		assertNull(s1.getIntroduceeB().lastRemoteMessageId);
+		assertEquals(s1.getIntroduceeB().lastRemoteMessageId,
+				s2.getIntroduceeB().lastRemoteMessageId);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testIntroducerSessionUnknownRole() throws FormatException {
+		IntroducerSession s = getIntroducerSession();
+		BdfDictionary d = sessionEncoder.encodeIntroducerSession(s);
+		d.put(SESSION_KEY_ROLE, 1337);
+		sessionParser.parseIntroducerSession(d);
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void testIntroducerSessionWrongRole() throws FormatException {
+		IntroducerSession s = getIntroducerSession();
+		BdfDictionary d = sessionEncoder.encodeIntroducerSession(s);
+		d.put(SESSION_KEY_ROLE, INTRODUCEE.getValue());
+		sessionParser.parseIntroducerSession(d);
+	}
+
+	@Test
+	public void testIntroduceeSession() throws FormatException {
+		IntroduceeSession s1 = getIntroduceeSession();
+		BdfDictionary d = sessionEncoder.encodeIntroduceeSession(s1);
+		IntroduceeSession s2 =
+				sessionParser.parseIntroduceeSession(groupId1, d);
+
+		assertEquals(LOCAL_ACCEPTED, s1.getState());
+		assertEquals(s1.getState(), s2.getState());
+		assertEquals(INTRODUCEE, s1.getRole());
+		assertEquals(s1.getRole(), s2.getRole());
+		assertEquals(sessionId, s1.getSessionId());
+		assertEquals(s1.getSessionId(), s2.getSessionId());
+		assertEquals(groupId1, s1.getContactGroupId());
+		assertEquals(s1.getContactGroupId(), s2.getContactGroupId());
+		assertEquals(author1, s1.getIntroducer());
+		assertEquals(s1.getIntroducer(), s2.getIntroducer());
+		assertArrayEquals(masterKey, s1.getMasterKey());
+		assertArrayEquals(s1.getMasterKey(), s2.getMasterKey());
+		assertEquals(transportKeys, s1.getTransportKeys());
+		assertEquals(s1.getTransportKeys(), s2.getTransportKeys());
+		assertEquals(localTimestamp, s1.getLocalTimestamp());
+		assertEquals(s1.getLocalTimestamp(), s2.getLocalTimestamp());
+		assertEquals(lastLocalMessageId, s1.getLastLocalMessageId());
+		assertEquals(s1.getLastLocalMessageId(), s2.getLastLocalMessageId());
+		assertEquals(lastRemoteMessageId, s1.getLastRemoteMessageId());
+		assertEquals(s1.getLastRemoteMessageId(), s2.getLastRemoteMessageId());
+
+		// check local
+		assertTrue(s1.getLocal().alice);
+		assertEquals(s1.getLocal().alice, s2.getLocal().alice);
+		assertEquals(lastLocalMessageId, s1.getLocal().lastMessageId);
+		assertEquals(s1.getLocal().lastMessageId, s2.getLocal().lastMessageId);
+		assertEquals(localTimestamp, s1.getLocal().lastMessageTimestamp);
+		assertEquals(s1.getLocal().lastMessageTimestamp,
+				s2.getLocal().lastMessageTimestamp);
+		assertArrayEquals(ephemeralPublicKey, s1.getLocal().ephemeralPublicKey);
+		assertArrayEquals(s1.getLocal().ephemeralPublicKey,
+				s2.getLocal().ephemeralPublicKey);
+		assertArrayEquals(ephemeralPrivateKey,
+				s1.getLocal().ephemeralPrivateKey);
+		assertArrayEquals(s1.getLocal().ephemeralPrivateKey,
+				s2.getLocal().ephemeralPrivateKey);
+		assertEquals(transportProperties, s1.getLocal().transportProperties);
+		assertEquals(s1.getLocal().transportProperties,
+				s2.getLocal().transportProperties);
+		assertEquals(acceptTimestamp, s1.getLocal().acceptTimestamp);
+		assertEquals(s1.getLocal().acceptTimestamp,
+				s2.getLocal().acceptTimestamp);
+		assertArrayEquals(localMacKey, s1.getLocal().macKey);
+		assertArrayEquals(s1.getLocal().macKey, s2.getLocal().macKey);
+
+		// check remote
+		assertFalse(s1.getRemote().alice);
+		assertEquals(s1.getRemote().alice, s2.getRemote().alice);
+		assertEquals(author2, s1.getRemote().author);
+		assertEquals(s1.getRemote().author, s2.getRemote().author);
+		assertEquals(lastRemoteMessageId, s1.getRemote().lastMessageId);
+		assertEquals(s1.getRemote().lastMessageId,
+				s2.getRemote().lastMessageId);
+		assertArrayEquals(remoteEphemeralPublicKey,
+				s1.getRemote().ephemeralPublicKey);
+		assertArrayEquals(s1.getRemote().ephemeralPublicKey,
+				s2.getRemote().ephemeralPublicKey);
+		assertEquals(remoteTransportProperties,
+				s1.getRemote().transportProperties);
+		assertEquals(s1.getRemote().transportProperties,
+				s2.getRemote().transportProperties);
+		assertEquals(remoteAcceptTimestamp, s1.getRemote().acceptTimestamp);
+		assertEquals(s1.getRemote().acceptTimestamp,
+				s2.getRemote().acceptTimestamp);
+		assertArrayEquals(remoteMacKey, s1.getRemote().macKey);
+		assertArrayEquals(s1.getRemote().macKey, s2.getRemote().macKey);
+	}
+
+	@Test
+	public void testIntroduceeSessionWithNulls() throws FormatException {
+		IntroduceeSession s1 = IntroduceeSession
+				.getInitial(groupId1, sessionId, author1, false, author2);
+
+		BdfDictionary d = sessionEncoder.encodeIntroduceeSession(s1);
+		IntroduceeSession s2 =
+				sessionParser.parseIntroduceeSession(groupId1, d);
+
+		assertNull(s1.getLastLocalMessageId());
+		assertEquals(s1.getLastLocalMessageId(), s2.getLastLocalMessageId());
+		assertNull(s1.getLastRemoteMessageId());
+		assertEquals(s1.getLastRemoteMessageId(), s2.getLastRemoteMessageId());
+		assertNull(s1.getMasterKey());
+		assertEquals(s1.getMasterKey(), s2.getMasterKey());
+		assertNull(s1.getTransportKeys());
+		assertEquals(s1.getTransportKeys(), s2.getTransportKeys());
+
+		// check local
+		assertNull(s1.getLocal().lastMessageId);
+		assertEquals(s1.getLocal().lastMessageId, s2.getLocal().lastMessageId);
+		assertNull(s1.getLocal().ephemeralPublicKey);
+		assertEquals(s1.getLocal().ephemeralPublicKey,
+				s2.getLocal().ephemeralPublicKey);
+		assertNull(s1.getLocal().ephemeralPrivateKey);
+		assertEquals(s1.getLocal().ephemeralPrivateKey,
+				s2.getLocal().ephemeralPrivateKey);
+		assertNull(s1.getLocal().transportProperties);
+		assertEquals(s1.getLocal().transportProperties,
+				s2.getLocal().transportProperties);
+		assertNull(s1.getLocal().macKey);
+		assertEquals(s1.getLocal().macKey, s2.getLocal().macKey);
+
+		// check remote
+		assertNull(s1.getRemote().lastMessageId);
+		assertEquals(s1.getRemote().lastMessageId,
+				s2.getRemote().lastMessageId);
+		assertNull(s1.getRemote().ephemeralPublicKey);
+		assertEquals(s1.getRemote().ephemeralPublicKey,
+				s2.getRemote().ephemeralPublicKey);
+		assertNull(s1.getRemote().transportProperties);
+		assertEquals(s1.getRemote().transportProperties,
+				s2.getRemote().transportProperties);
+		assertNull(s1.getRemote().macKey);
+		assertEquals(s1.getRemote().macKey, s2.getRemote().macKey);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testIntroduceeSessionUnknownRole() throws FormatException {
+		IntroduceeSession s = getIntroduceeSession();
+		BdfDictionary d = sessionEncoder.encodeIntroduceeSession(s);
+		d.put(SESSION_KEY_ROLE, 1337);
+		sessionParser.parseIntroduceeSession(groupId1, d);
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void testIntroduceeSessionWrongRole() throws FormatException {
+		IntroduceeSession s = getIntroduceeSession();
+		BdfDictionary d = sessionEncoder.encodeIntroduceeSession(s);
+		d.put(SESSION_KEY_ROLE, INTRODUCER.getValue());
+		sessionParser.parseIntroduceeSession(groupId1, d);
+	}
+
+	private IntroducerSession getIntroducerSession() {
+		Introducee introducee1 =
+				new Introducee(sessionId, groupId1, author1, localTimestamp,
+						lastLocalMessageId, lastRemoteMessageId);
+		Introducee introducee2 =
+				new Introducee(sessionId, groupId2, author2, localTimestamp2,
+						lastLocalMessageId2, lastRemoteMessageId2);
+		return new IntroducerSession(sessionId, AWAIT_AUTHS,
+				requestTimestamp, introducee1, introducee2);
+	}
+
+	private IntroduceeSession getIntroduceeSession() {
+		Local local = new Local(true, lastLocalMessageId, localTimestamp,
+				ephemeralPublicKey, ephemeralPrivateKey, transportProperties,
+				acceptTimestamp, localMacKey);
+		Remote remote = new Remote(false, author2, lastRemoteMessageId,
+				remoteEphemeralPublicKey,  remoteTransportProperties,
+				remoteAcceptTimestamp, remoteMacKey);
+		return new IntroduceeSession(sessionId, LOCAL_ACCEPTED,
+				requestTimestamp, groupId1, author1, local, remote,
+				masterKey, transportKeys);
+	}
+
+	private void assertIntroduceeEquals(Introducee i1, Introducee i2) {
+		assertEquals(i1.author, i2.author);
+		assertEquals(i1.groupId, i2.groupId);
+		assertEquals(i1.localTimestamp, i2.localTimestamp);
+		assertEquals(i1.lastLocalMessageId, i2.lastLocalMessageId);
+		assertEquals(i1.lastRemoteMessageId, i2.lastRemoteMessageId);
+	}
+
+}
diff --git a/briar-core/src/test/java/org/briarproject/briar/privategroup/GroupMessageValidatorTest.java b/briar-core/src/test/java/org/briarproject/briar/privategroup/GroupMessageValidatorTest.java
index 196ce09ca663ac28a2fe1370bdd96e0de7b67d8a..3148da439fe6f99daf90d57e3ae5a5088a2a2dca 100644
--- a/briar-core/src/test/java/org/briarproject/briar/privategroup/GroupMessageValidatorTest.java
+++ b/briar-core/src/test/java/org/briarproject/briar/privategroup/GroupMessageValidatorTest.java
@@ -368,14 +368,6 @@ public class GroupMessageValidatorTest extends ValidatorTestCase {
 				.getBoolean(KEY_INITIAL_JOIN_MSG));
 	}
 
-	private void expectParseAuthor(BdfList authorList, Author author)
-			throws Exception {
-		context.checking(new Expectations() {{
-			oneOf(clientHelper).parseAndValidateAuthor(authorList);
-			will(returnValue(author));
-		}});
-	}
-
 	private void expectRejectAuthor(BdfList authorList) throws Exception {
 		context.checking(new Expectations() {{
 			oneOf(clientHelper).parseAndValidateAuthor(authorList);
diff --git a/briar-core/src/test/java/org/briarproject/briar/sharing/SharingValidatorTest.java b/briar-core/src/test/java/org/briarproject/briar/sharing/SharingValidatorTest.java
index 157c4ce79d9f89d26cae4fc6e57ae0183522e42d..f001fb68008107c10380a66001fde553cca7e97e 100644
--- a/briar-core/src/test/java/org/briarproject/briar/sharing/SharingValidatorTest.java
+++ b/briar-core/src/test/java/org/briarproject/briar/sharing/SharingValidatorTest.java
@@ -150,7 +150,7 @@ public abstract class SharingValidatorTest extends ValidatorTestCase {
 	}
 
 	void assertExpectedContext(BdfMessageContext messageContext,
-			@Nullable MessageId previousMsgId) throws FormatException {
+			@Nullable MessageId previousMsgId) {
 		Collection<MessageId> dependencies = messageContext.getDependencies();
 		if (previousMsgId == null) {
 			assertTrue(dependencies.isEmpty());
diff --git a/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTestComponent.java b/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTestComponent.java
index 1b2344d3add2760d94dab0aae8f09babbc052a0d..ecaf60d1b9763e069018b243312948c3906dc80f 100644
--- a/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTestComponent.java
+++ b/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTestComponent.java
@@ -36,7 +36,10 @@ import org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager
 import org.briarproject.briar.blog.BlogModule;
 import org.briarproject.briar.client.BriarClientModule;
 import org.briarproject.briar.forum.ForumModule;
+import org.briarproject.briar.introduction.IntroductionCryptoIntegrationTest;
 import org.briarproject.briar.introduction.IntroductionModule;
+import org.briarproject.briar.introduction.MessageEncoderParserIntegrationTest;
+import org.briarproject.briar.introduction.SessionEncoderParserIntegrationTest;
 import org.briarproject.briar.messaging.MessagingModule;
 import org.briarproject.briar.privategroup.PrivateGroupModule;
 import org.briarproject.briar.privategroup.invitation.GroupInvitationModule;
diff --git a/briar-core/src/test/java/org/briarproject/briar/test/BriarTestUtils.java b/briar-core/src/test/java/org/briarproject/briar/test/BriarTestUtils.java
index d29fc0b54167dba4eb80b43c77575fd994925114..9f71087ebd38fc5d2e3ce8883fc9b37c13c089ce 100644
--- a/briar-core/src/test/java/org/briarproject/briar/test/BriarTestUtils.java
+++ b/briar-core/src/test/java/org/briarproject/briar/test/BriarTestUtils.java
@@ -1,10 +1,18 @@
 package org.briarproject.briar.test;
 
+import org.briarproject.bramble.api.crypto.CryptoComponent;
+import org.briarproject.bramble.api.crypto.KeyPair;
 import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorFactory;
+import org.briarproject.bramble.api.identity.LocalAuthor;
 import org.briarproject.bramble.api.sync.GroupId;
 import org.briarproject.briar.api.client.MessageTracker;
 import org.briarproject.briar.api.client.MessageTracker.GroupCount;
 
+import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
+import static org.briarproject.bramble.util.StringUtils.getRandomString;
 import static org.junit.Assert.assertEquals;
 
 public class BriarTestUtils {
@@ -25,4 +33,17 @@ public class BriarTestUtils {
 		assertEquals(unreadCount, c1.getUnreadCount());
 	}
 
+	public static Author getRealAuthor(AuthorFactory authorFactory) {
+		return authorFactory.createAuthor(getRandomString(5),
+				getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+	}
+
+	public static LocalAuthor getRealLocalAuthor(
+			CryptoComponent cryptoComponent, AuthorFactory authorFactory) {
+		KeyPair keyPair = cryptoComponent.generateSignatureKeyPair();
+		return authorFactory.createLocalAuthor(getRandomString(5),
+				keyPair.getPublic().getEncoded(),
+				keyPair.getPrivate().getEncoded());
+	}
+
 }