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/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-api/src/main/java/org/briarproject/briar/api/introduction2/IntroductionConstants.java b/briar-api/src/main/java/org/briarproject/briar/api/introduction2/IntroductionConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..6b91b33c023dd0b349ff1d564b5e3f25f9010753
--- /dev/null
+++ b/briar-api/src/main/java/org/briarproject/briar/api/introduction2/IntroductionConstants.java
@@ -0,0 +1,13 @@
+package org.briarproject.briar.api.introduction2;
+
+import static org.briarproject.bramble.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
+
+public interface IntroductionConstants {
+
+	/**
+	 * The maximum length of the introducer's optional message to the
+	 * introducees in UTF-8 bytes.
+	 */
+	int MAX_REQUEST_MESSAGE_LENGTH = MAX_MESSAGE_BODY_LENGTH - 1024;
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction2/AbortMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/AbortMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..9d7112407614ec456ce65647174fe272cc09f1c8
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/AbortMessage.java
@@ -0,0 +1,27 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	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/introduction2/AcceptMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/AcceptMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..b9bf92b6c40ee0c8b888585cf68265f996a8297a
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/AcceptMessage.java
@@ -0,0 +1,46 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	private final SessionId sessionId;
+	private final byte[] ephemeralPublicKey;
+	private final Map<TransportId, TransportProperties> transportProperties;
+
+	protected AcceptMessage(MessageId messageId, GroupId groupId,
+			long timestamp, @Nullable MessageId previousMessageId,
+			SessionId sessionId,
+			byte[] ephemeralPublicKey,
+			Map<TransportId, TransportProperties> transportProperties) {
+		super(messageId, groupId, timestamp, previousMessageId);
+		this.sessionId = sessionId;
+		this.ephemeralPublicKey = ephemeralPublicKey;
+		this.transportProperties = transportProperties;
+	}
+
+	public SessionId getSessionId() {
+		return sessionId;
+	}
+
+	public byte[] getEphemeralPublicKey() {
+		return ephemeralPublicKey;
+	}
+
+	public Map<TransportId, TransportProperties> getTransportProperties() {
+		return transportProperties;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction2/ActivateMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/ActivateMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..0d5e935b489ff703998ceecea70c13fa07557a91
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/ActivateMessage.java
@@ -0,0 +1,26 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	private final SessionId sessionId;
+
+	protected ActivateMessage(MessageId messageId, GroupId groupId,
+			long timestamp, 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/introduction2/AuthMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/AuthMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..5833a64d689e540fb0b53b1d37e5199055d8192e
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/AuthMessage.java
@@ -0,0 +1,38 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	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/introduction2/DeclineMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/DeclineMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..c419704c5c72e2e3aed1438f893a57ea372e2302
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/DeclineMessage.java
@@ -0,0 +1,28 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	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/introduction2/IntroductionMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..24a252f5ef31170a37415c851dee3c2d152c435c
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionMessage.java
@@ -0,0 +1,45 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	private final MessageId messageId;
+	private final GroupId groupId;
+	private final long timestamp;
+	@Nullable
+	private final MessageId previousMessageId;
+
+	IntroductionMessage(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/introduction2/IntroductionModule.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..1a64538ea11db9ce614d96a50336a5e1e20007d5
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionModule.java
@@ -0,0 +1,39 @@
+package org.briarproject.briar.introduction2;
+
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.MetadataEncoder;
+import org.briarproject.bramble.api.sync.ValidationManager;
+import org.briarproject.bramble.api.system.Clock;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+
+import static org.briarproject.briar.api.introduction.IntroductionManager.CLIENT_ID;
+
+@Module
+public class IntroductionModule {
+
+	public static class EagerSingletons {
+		@Inject
+		IntroductionValidator introductionValidator;
+	}
+
+	@Provides
+	@Singleton
+	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;
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionValidator.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce8359701fc5aff97db422fd4423bcde7b4f8653
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/IntroductionValidator.java
@@ -0,0 +1,167 @@
+package org.briarproject.briar.introduction2;
+
+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;
+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 java.util.Collections;
+
+import javax.annotation.concurrent.Immutable;
+
+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.plugin.TransportId.MAX_TRANSPORT_ID_LENGTH;
+import static org.briarproject.bramble.util.ValidationUtils.checkLength;
+import static org.briarproject.bramble.util.ValidationUtils.checkSize;
+import static org.briarproject.briar.api.introduction2.IntroductionConstants.MAX_REQUEST_MESSAGE_LENGTH;
+import static org.briarproject.briar.introduction2.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction2.MessageType.AUTH;
+import static org.briarproject.briar.introduction2.MessageType.REQUEST;
+
+
+@Immutable
+@NotNullByDefault
+class IntroductionValidator extends BdfMessageValidator {
+
+	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 DECLINE:
+			case ACTIVATE:
+			case ABORT:
+				return validateOtherMessage(type, m, body);
+			default:
+				throw new FormatException();
+		}
+	}
+
+	private BdfMessageContext validateRequestMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 4);
+
+		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(REQUEST, m.getTimestamp(), false,
+						false, false, false, false);
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
+		} else {
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
+		}
+	}
+
+	private BdfMessageContext validateAcceptMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 5);
+
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
+
+		byte[] previousMessageId = body.getRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
+
+		byte[] ephemeralPublicKey = body.getRaw(3);
+		checkLength(ephemeralPublicKey, 0, MAX_PUBLIC_KEY_LENGTH);
+
+		BdfDictionary transportProperties = body.getDictionary(4);
+		if (transportProperties.size() < 1) throw new FormatException();
+		for (String tId : transportProperties.keySet()) {
+			checkLength(tId, 1, MAX_TRANSPORT_ID_LENGTH);
+			BdfDictionary tProps = transportProperties.getDictionary(tId);
+			clientHelper.parseAndValidateTransportProperties(tProps);
+		}
+
+		SessionId sessionId = new SessionId(sessionIdBytes);
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(ACCEPT, sessionId, m.getTimestamp(), false,
+						false, false);
+		MessageId dependency = new MessageId(previousMessageId);
+		return new BdfMessageContext(meta,
+				Collections.singletonList(dependency));
+	}
+
+	private BdfMessageContext validateAuthMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 5);
+
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
+
+		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 BdfMessageContext validateOtherMessage(MessageType type,
+			Message m, BdfList body) throws FormatException {
+		checkSize(body, 3);
+
+		byte[] sessionIdBytes = body.getRaw(1);
+		checkLength(sessionIdBytes, UniqueId.LENGTH);
+
+		byte[] previousMessageId = body.getRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
+
+		SessionId sessionId = new SessionId(sessionIdBytes);
+		BdfDictionary meta = messageEncoder
+				.encodeMetadata(type, sessionId, m.getTimestamp(), false, false,
+						false);
+		MessageId dependency = new MessageId(previousMessageId);
+		return new BdfMessageContext(meta,
+				Collections.singletonList(dependency));
+	}
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction2/MessageEncoder.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/MessageEncoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..25371b68cb66710b0878045abe1348d926d1909d
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/MessageEncoder.java
@@ -0,0 +1,15 @@
+package org.briarproject.briar.introduction2;
+
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.briar.api.client.SessionId;
+
+interface MessageEncoder {
+
+	BdfDictionary encodeRequestMetadata(MessageType type,
+			long timestamp, boolean local, boolean read, boolean visible,
+			boolean available, boolean accepted);
+
+	BdfDictionary encodeMetadata(MessageType type, SessionId sessionId,
+			long timestamp, boolean local, boolean read, boolean visible);
+
+}
diff --git a/briar-core/src/main/java/org/briarproject/briar/introduction2/MessageType.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/MessageType.java
new file mode 100644
index 0000000000000000000000000000000000000000..bb34b7da3e8040c979ded40546b423dd46172dc1
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/MessageType.java
@@ -0,0 +1,29 @@
+package org.briarproject.briar.introduction2;
+
+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/introduction2/RequestMessage.java b/briar-core/src/main/java/org/briarproject/briar/introduction2/RequestMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..470fb39662e3cce6f24d2c21bc3d7402f6657d7a
--- /dev/null
+++ b/briar-core/src/main/java/org/briarproject/briar/introduction2/RequestMessage.java
@@ -0,0 +1,36 @@
+package org.briarproject.briar.introduction2;
+
+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 IntroductionMessage {
+
+	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/test/java/org/briarproject/briar/introduction2/IntroductionValidatorTest.java b/briar-core/src/test/java/org/briarproject/briar/introduction2/IntroductionValidatorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..fc0109497993695c3a56ac37407cc224fd6bf0c9
--- /dev/null
+++ b/briar-core/src/test/java/org/briarproject/briar/introduction2/IntroductionValidatorTest.java
@@ -0,0 +1,426 @@
+package org.briarproject.briar.introduction2;
+
+import org.briarproject.bramble.api.FormatException;
+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.sync.MessageId;
+import org.briarproject.bramble.test.ValidatorTestCase;
+import org.briarproject.briar.api.client.SessionId;
+import org.jmock.Expectations;
+import org.junit.Test;
+
+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.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.MAX_INTRODUCTION_MESSAGE_LENGTH;
+import static org.briarproject.briar.introduction2.MessageType.ABORT;
+import static org.briarproject.briar.introduction2.MessageType.ACCEPT;
+import static org.briarproject.briar.introduction2.MessageType.ACTIVATE;
+import static org.briarproject.briar.introduction2.MessageType.AUTH;
+import static org.briarproject.briar.introduction2.MessageType.DECLINE;
+import static org.briarproject.briar.introduction2.MessageType.REQUEST;
+import static org.junit.Assert.assertEquals;
+
+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_INTRODUCTION_MESSAGE_LENGTH);
+	private final BdfDictionary meta = new BdfDictionary();
+	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);
+
+	//
+	// Introduction REQUEST
+	//
+
+	@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);
+
+		assertExpectedContext(messageContext, previousMsgId);
+	}
+
+	@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 testAcceptsRequestWithMessageNull() throws Exception {
+		BdfList body = BdfList.of(REQUEST.getValue(), null, authorList, null);
+
+		expectParseAuthor(authorList, author);
+		expectEncodeRequestMetadata();
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, null);
+	}
+
+	@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 testRejectsTooLongBodyForRequest() throws Exception {
+		BdfList body =
+				BdfList.of(REQUEST.getValue(), null, authorList, text, null);
+		validator.validateMessage(message, group, body);
+	}
+
+	@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),
+				transportProperties);
+		context.checking(new Expectations() {{
+			oneOf(clientHelper).parseAndValidateTransportProperties(
+					transportProperties.getDictionary("transportId"));
+		}});
+		expectEncodeMetadata(ACCEPT);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, previousMsgId);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(),
+				getRandomBytes(MAX_PUBLIC_KEY_LENGTH));
+		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),
+				transportProperties, null);
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidSessionIdForAccept() throws Exception {
+		BdfList body =
+				BdfList.of(ACCEPT.getValue(), null, previousMsgId.getBytes(),
+						getRandomBytes(MAX_PUBLIC_KEY_LENGTH),
+						transportProperties);
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidPreviousMsgIdForAccept() throws Exception {
+		BdfList body = BdfList.of(ACCEPT.getValue(), sessionId.getBytes(),
+				null, getRandomBytes(MAX_PUBLIC_KEY_LENGTH),
+				transportProperties);
+		validator.validateMessage(message, group, body);
+	}
+
+	@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), transportProperties);
+		validator.validateMessage(message, group, body);
+	}
+
+	@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), new BdfDictionary());
+		validator.validateMessage(message, group, body);
+	}
+
+	//
+	// Introduction DECLINE
+	//
+
+	@Test
+	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(expected = FormatException.class)
+	public void testRejectsTooShortBodyForDecline() throws Exception {
+		BdfList body = BdfList.of(DECLINE.getValue(), sessionId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
+
+	@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);
+	}
+
+	@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 testRejectsInvalidPreviousMsgIdForDecline() throws Exception {
+		BdfList body = BdfList.of(DECLINE.getValue(), sessionId.getBytes(),
+				null);
+		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 testRejectsTooLongBodyForAuth() throws Exception {
+		BdfList body = BdfList.of(AUTH.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes(), mac, signature, null);
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	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);
+	}
+
+	@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);
+	}
+
+	@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);
+	}
+
+	@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);
+	}
+
+	@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 ACTIVATE
+	//
+
+	@Test
+	public void testAcceptsActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes());
+
+		expectEncodeMetadata(ACTIVATE);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, previousMsgId);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.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(), null);
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidSessionIdForActivate() throws Exception {
+		BdfList body =
+				BdfList.of(ACTIVATE.getValue(), null, previousMsgId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsInvalidPreviousMsgIdForActivate() throws Exception {
+		BdfList body = BdfList.of(ACTIVATE.getValue(), sessionId.getBytes(),
+				null);
+		validator.validateMessage(message, group, body);
+	}
+
+	//
+	// Introduction ABORT
+	//
+
+	@Test
+	public void testAcceptsAbort() throws Exception {
+		BdfList body = BdfList.of(ABORT.getValue(), sessionId.getBytes(),
+				previousMsgId.getBytes());
+
+		expectEncodeMetadata(ABORT);
+		BdfMessageContext messageContext =
+				validator.validateMessage(message, group, body);
+
+		assertExpectedContext(messageContext, previousMsgId);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testRejectsTooShortBodyForAbort() throws Exception {
+		BdfList body = BdfList.of(ABORT.getValue(), sessionId.getBytes());
+		validator.validateMessage(message, group, body);
+	}
+
+	@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 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(),
+				null);
+		validator.validateMessage(message, group, body);
+	}
+
+	//
+	// Introduction Helper Methods
+	//
+
+	private void expectEncodeRequestMetadata() {
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder)
+					.encodeRequestMetadata(REQUEST, message.getTimestamp(),
+							false, false,
+							false, false, false);
+			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/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());