diff --git a/briar-android/AndroidManifest.xml b/briar-android/AndroidManifest.xml
index cab5c80b94ca3db32355596785e7ee1a249a4138..cb723afa40e7011d2ce44d7eb0810328c5093bc1 100644
--- a/briar-android/AndroidManifest.xml
+++ b/briar-android/AndroidManifest.xml
@@ -14,11 +14,13 @@
 		/>
 
 	<uses-feature android:name="android.hardware.bluetooth"/>
+	<uses-feature android:name="android.hardware.camera" />
 
 	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 	<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
 	<uses-permission android:name="android.permission.BLUETOOTH" />
 	<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
+	<uses-permission android:name="android.permission.CAMERA" />
 	<uses-permission android:name="android.permission.INTERNET" />
 	<uses-permission android:name="android.permission.READ_LOGS"/>
 	<uses-permission android:name="android.permission.VIBRATE" />
@@ -165,6 +167,16 @@
 				android:value=".android.NavDrawerActivity"
 				/>
 		</activity>
+		<activity
+			android:name=".android.keyagreement.KeyAgreementActivity"
+			android:label="@string/add_contact_title"
+			android:theme="@style/BriarThemeNoActionBar.Default"
+			android:parentActivityName=".android.NavDrawerActivity">
+			<meta-data
+				android:name="android.support.PARENT_ACTIVITY"
+				android:value=".android.NavDrawerActivity"
+				/>
+		</activity>
 		<activity
 			android:name=".android.StartupFailureActivity"
 			android:label="@string/startup_failed_activity_title">
diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index 81228e91863e36d784813798f1c84c57615a3254..7e79c0dffb8beccb996572f080d61001d71f22e5 100644
--- a/briar-android/build.gradle
+++ b/briar-android/build.gradle
@@ -46,7 +46,7 @@ dependencyVerification {
 			'com.android.support:recyclerview-v7:7606373da0931a1e62588335465a0e390cd676c98117edab29220317495faefd',
 			'info.guardianproject.panic:panic:a7ed9439826db2e9901649892cf9afbe76f00991b768d8f4c26332d7c9406cb2',
 			'info.guardianproject.trustedintents:trustedintents:6221456d8821a8d974c2acf86306900237cf6afaaa94a4c9c44e161350f80f3e',
-			'com.android.support:support-annotations:f347a35b9748a4103b39a6714a77e2100f488d623fd6268e259c177b200e9d82'
+			'de.hdodenhof:circleimageview:c76d936395b50705a3f98c9220c22d2599aeb9e609f559f6048975cfc1f686b8',
 	]
 }
 
@@ -97,4 +97,4 @@ android {
 	lintOptions {
 		abortOnError false
 	}
-}
\ No newline at end of file
+}
diff --git a/briar-android/res/layout/activity_nav_drawer.xml b/briar-android/res/layout/activity_nav_drawer.xml
index f7f90efa8e99a2be3cbf94dc1dc30995d20cc566..5b167e4ffe3951eb5ed034676dadef175fa7476f 100644
--- a/briar-android/res/layout/activity_nav_drawer.xml
+++ b/briar-android/res/layout/activity_nav_drawer.xml
@@ -2,65 +2,12 @@
 <android.support.v4.widget.DrawerLayout
 	android:id="@+id/drawer_layout"
 	xmlns:android="http://schemas.android.com/apk/res/android"
-	xmlns:tools="http://schemas.android.com/tools"
 	android:layout_width="match_parent"
 	android:layout_height="match_parent">
 
 	<!-- The first child(root) is the content view -->
-	<LinearLayout
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:orientation="vertical">
-
-		<android.support.v7.widget.Toolbar
-			android:id="@+id/toolbar"
-			style="@style/BriarToolbar"
-			android:layout_width="match_parent"
-			android:layout_height="wrap_content"
-			android:background="?attr/colorPrimary"
-			android:minHeight="?attr/actionBarSize"
-			/>
-
-		<FrameLayout
-			android:layout_width="match_parent"
-			android:layout_height="match_parent">
-
-			<FrameLayout
-				android:id="@+id/content_fragment"
-				android:layout_width="match_parent"
-				android:layout_height="match_parent"
-				android:background="@color/default_background"/>
-
-			<RelativeLayout
-				android:id="@+id/container_progress"
-				android:layout_width="match_parent"
-				android:layout_height="match_parent"
-				android:layout_gravity="center"
-				android:background="@color/default_background"
-				android:visibility="invisible"
-				tools:visibility="visible">
-
-				<ProgressBar
-					android:id="@+id/progress_bar"
-					style="?android:attr/progressBarStyleLargeInverse"
-					android:layout_width="wrap_content"
-					android:layout_height="wrap_content"
-					android:layout_centerInParent="true"/>
-
-				<TextView
-					android:id="@+id/title_progress_bar"
-					android:layout_width="match_parent"
-					android:layout_height="wrap_content"
-					android:layout_below="@id/progress_bar"
-					android:gravity="center"
-					android:paddingTop="@dimen/margin_large"
-					tools:text="progress bar title"
-					/>
-
-			</RelativeLayout>
-
-		</FrameLayout>
-	</LinearLayout>
+	<include
+		layout="@layout/activity_with_loading"/>
 
 	<!-- The second child is the menu -->
 	<include
diff --git a/briar-android/res/layout/activity_with_loading.xml b/briar-android/res/layout/activity_with_loading.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8d22ba321a5a950e1df4265cbeb0e98c7df25615
--- /dev/null
+++ b/briar-android/res/layout/activity_with_loading.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	android:orientation="vertical">
+
+	<android.support.v7.widget.Toolbar
+		android:id="@+id/toolbar"
+		style="@style/BriarToolbar"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:background="?attr/colorPrimary"
+		android:minHeight="?attr/actionBarSize"
+		/>
+
+	<FrameLayout
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
+		<FrameLayout
+			android:id="@+id/content_fragment"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			android:background="@color/default_background"/>
+
+		<RelativeLayout
+			android:id="@+id/container_progress"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			android:layout_gravity="center"
+			android:background="@color/default_background"
+			android:visibility="invisible"
+			tools:visibility="visible">
+
+			<ProgressBar
+				android:id="@+id/progress_bar"
+				style="?android:attr/progressBarStyleLargeInverse"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:layout_centerInParent="true"/>
+
+			<TextView
+				android:id="@+id/title_progress_bar"
+				android:layout_width="match_parent"
+				android:layout_height="wrap_content"
+				android:layout_below="@id/progress_bar"
+				android:gravity="center"
+				android:paddingTop="@dimen/margin_large"
+				tools:text="progress bar title"
+				/>
+
+		</RelativeLayout>
+
+	</FrameLayout>
+</LinearLayout>
\ No newline at end of file
diff --git a/briar-android/res/layout/fragment_keyagreement_qr.xml b/briar-android/res/layout/fragment_keyagreement_qr.xml
new file mode 100644
index 0000000000000000000000000000000000000000..31f15925b70a355c35019d28c9cd81b35d81be5d
--- /dev/null
+++ b/briar-android/res/layout/fragment_keyagreement_qr.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+	android:id="@+id/qr_layout"
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	android:orientation="vertical"
+	android:weightSum="2">
+
+	<FrameLayout
+		android:layout_width="match_parent"
+		android:layout_height="0dp"
+		android:layout_weight="1"
+		android:background="@android:color/black"
+		android:gravity="center">
+
+		<RelativeLayout
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			android:background="@android:color/background_light">
+
+			<LinearLayout
+				android:layout_width="match_parent"
+				android:layout_height="wrap_content"
+				android:layout_centerInParent="true"
+				android:gravity="center"
+				android:orientation="vertical"
+				android:padding="@dimen/margin_medium">
+
+				<ProgressBar
+					style="?android:attr/progressBarStyleInverse"
+					android:layout_width="wrap_content"
+					android:layout_height="wrap_content"
+					android:paddingTop="@dimen/margin_large"/>
+
+				<TextView
+					android:id="@+id/connect_status"
+					android:layout_width="match_parent"
+					android:layout_height="wrap_content"
+					android:gravity="center"
+					android:paddingTop="@dimen/margin_large"
+					tools:text="Connection failed"/>
+			</LinearLayout>
+		</RelativeLayout>
+
+		<org.briarproject.android.util.CameraView
+			android:id="@+id/camera_view"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"/>
+	</FrameLayout>
+
+	<ImageView
+		android:id="@+id/qr_code"
+		android:layout_width="match_parent"
+		android:layout_height="0dp"
+		android:layout_weight="1"
+		android:scaleType="fitCenter"/>
+</LinearLayout>
\ No newline at end of file
diff --git a/briar-android/res/values/strings.xml b/briar-android/res/values/strings.xml
index cf63cf76bbcfb443dd4c21583f4121cfd6685ed5..f75d8a90d2a549e18c3bbd8fc1bd0938867c8f70 100644
--- a/briar-android/res/values/strings.xml
+++ b/briar-android/res/values/strings.xml
@@ -34,6 +34,7 @@
 	<string name="contact_list_title">Contacts</string>
 	<string name="no_contacts">No contacts</string>
 	<string name="add_contact_title">Add a Contact</string>
+	<string name="add_contact_title_step">Add a Contact - Step %1$d/%2$d</string>
 	<string name="your_nickname">Choose the identity you want to use:</string>
 	<string name="face_to_face">You must be face-to-face with the person you want to add as a contact. This will prevent anyone from impersonating you or reading your messages in future.</string>
 	<string name="continue_button">Continue</string>
@@ -52,6 +53,14 @@
 	<string name="codes_do_not_match">Codes do not match</string>
 	<string name="interfering">This could mean that someone is trying to interfere with your connection</string>
 	<string name="contact_added_toast">Contact added: %s</string>
+	<string name="contact_already_exists">Contact %s already exists</string>
+	<string name="contact_exchange_failed">Contact exchange failed</string>
+	<string name="scan_qr_code">Scan QR code</string>
+	<string name="qr_code_invalid">The QR code is invalid</string>
+	<string name="connecting_to_device">Connecting to device\u2026</string>
+	<string name="authenticating_with_device">Authenticating with device\u2026</string>
+	<string name="connection_aborted_local">Connection aborted by us! This could mean that someone is trying to interfere with your connection</string>
+	<string name="connection_aborted_remote">Connection aborted by your contact! This could mean that someone is trying to interfere with your connection</string>
 	<string name="no_private_messages">No messages</string>
 	<string name="private_message_hint">Type message</string>
 	<string name="message_sent_toast">Message sent</string>
diff --git a/briar-android/src/org/briarproject/android/AndroidComponent.java b/briar-android/src/org/briarproject/android/AndroidComponent.java
index 4db188d9f60f2fa9618ec5333aada3a0c4f44542..a00d6fb1293179d1ed9f9282416f6f7e8e7494b2 100644
--- a/briar-android/src/org/briarproject/android/AndroidComponent.java
+++ b/briar-android/src/org/briarproject/android/AndroidComponent.java
@@ -13,6 +13,9 @@ import org.briarproject.android.forum.ShareForumActivity;
 import org.briarproject.android.forum.WriteForumPostActivity;
 import org.briarproject.android.identity.CreateIdentityActivity;
 import org.briarproject.android.invitation.AddContactActivity;
+import org.briarproject.android.keyagreement.ChooseIdentityFragment;
+import org.briarproject.android.keyagreement.KeyAgreementActivity;
+import org.briarproject.android.keyagreement.ShowQrCodeFragment;
 import org.briarproject.android.panic.PanicPreferencesActivity;
 import org.briarproject.android.panic.PanicResponderActivity;
 import org.briarproject.plugins.AndroidPluginsModule;
@@ -44,6 +47,8 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	void inject(AddContactActivity activity);
 
+	void inject(KeyAgreementActivity activity);
+
 	void inject(ConversationActivity activity);
 
 	void inject(CreateIdentityActivity activity);
@@ -68,6 +73,10 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	void inject(ForumListFragment fragment);
 
+	void inject(ChooseIdentityFragment fragment);
+
+	void inject(ShowQrCodeFragment fragment);
+
 	// Eager singleton load
 	void inject(AndroidModule.EagerSingletons init);
 
diff --git a/briar-android/src/org/briarproject/android/BriarFragmentActivity.java b/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
index 7b1e6ff535aa49cca6fc6778d06bd705daf62229..6bef438cf98803912918baaee7c8ab2ea79e916a 100644
--- a/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
+++ b/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
@@ -42,7 +42,8 @@ public abstract class BriarFragmentActivity extends BriarActivity {
 
 	@Override
 	public void onBackPressed() {
-		if (getSupportFragmentManager().getBackStackEntryCount() == 0 &&
+		if (this instanceof NavDrawerActivity &&
+				getSupportFragmentManager().getBackStackEntryCount() == 0 &&
 				getSupportFragmentManager()
 						.findFragmentByTag(ContactListFragment.TAG) == null) {
 			/*
diff --git a/briar-android/src/org/briarproject/android/contact/ContactListFragment.java b/briar-android/src/org/briarproject/android/contact/ContactListFragment.java
index 30d7ecb5d9ddc0d783da2bbd958c26330c90e574..0d7c5d32356547ca4c661554b99c46866979422b 100644
--- a/briar-android/src/org/briarproject/android/contact/ContactListFragment.java
+++ b/briar-android/src/org/briarproject/android/contact/ContactListFragment.java
@@ -13,7 +13,7 @@ import org.briarproject.R;
 import org.briarproject.android.AndroidComponent;
 import org.briarproject.android.BriarApplication;
 import org.briarproject.android.fragment.BaseEventFragment;
-import org.briarproject.android.invitation.AddContactActivity;
+import org.briarproject.android.keyagreement.KeyAgreementActivity;
 import org.briarproject.android.util.BriarRecyclerView;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
@@ -107,7 +107,7 @@ public class ContactListFragment extends BaseEventFragment {
 			@Override
 			public void onClick(View v) {
 				startActivity(new Intent(getContext(),
-						AddContactActivity.class));
+						KeyAgreementActivity.class));
 			}
 		});
 
diff --git a/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..4615cd728625345b440d55e4e4fa8695d3f92e38
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java
@@ -0,0 +1,193 @@
+package org.briarproject.android.keyagreement;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.Spinner;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.fragment.BaseFragment;
+import org.briarproject.android.identity.CreateIdentityActivity;
+import org.briarproject.android.identity.LocalAuthorItem;
+import org.briarproject.android.identity.LocalAuthorItemComparator;
+import org.briarproject.android.identity.LocalAuthorSpinnerAdapter;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+
+import java.util.Collection;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.app.Activity.RESULT_OK;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.android.identity.LocalAuthorItem.NEW;
+
+public class ChooseIdentityFragment extends BaseFragment
+		implements OnItemSelectedListener {
+
+	interface IdentitySelectedListener {
+		void identitySelected(AuthorId localAuthorId);
+	}
+
+	private static final Logger LOG =
+			Logger.getLogger(ChooseIdentityFragment.class.getName());
+
+	public static final String TAG = "ChooseIdentityFragment";
+
+	private static final int REQUEST_CREATE_IDENTITY = 1;
+
+	private IdentitySelectedListener lsnr;
+	private LocalAuthorSpinnerAdapter adapter;
+	private Spinner spinner;
+	private View button;
+
+	private AuthorId localAuthorId;
+
+	// Fields that are accessed from background threads must be volatile
+	@Inject
+	protected volatile IdentityManager identityManager;
+
+	public static ChooseIdentityFragment newInstance() {
+		Bundle args = new Bundle();
+		ChooseIdentityFragment fragment = new ChooseIdentityFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public void onAttach(Context context) {
+		super.onAttach(context);
+		try {
+			lsnr = (IdentitySelectedListener) context;
+		} catch (ClassCastException e) {
+			throw new ClassCastException(
+					"Using class must implement IdentitySelectedListener");
+		}
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Override
+	public void injectActivity(AndroidComponent component) {
+		component.inject(this);
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater, ViewGroup container,
+			Bundle savedInstanceState) {
+		return inflater.inflate(R.layout.invitation_bluetooth_start, container,
+				false);
+	}
+
+	@Override
+	public void onViewCreated(View view, Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+
+		adapter = new LocalAuthorSpinnerAdapter(getActivity(), false);
+		spinner = (Spinner) view.findViewById(R.id.spinner);
+		spinner.setAdapter(adapter);
+		spinner.setOnItemSelectedListener(this);
+
+		button = view.findViewById(R.id.continueButton);
+		button.setEnabled(false);
+		button.setOnClickListener(
+				new OnClickListener() {
+					@Override
+					public void onClick(View view) {
+						lsnr.identitySelected(localAuthorId);
+					}
+				});
+
+		loadLocalAuthors();
+	}
+
+	private void loadLocalAuthors() {
+		listener.runOnDbThread(new Runnable() {
+			public void run() {
+				try {
+					long now = System.currentTimeMillis();
+					Collection<LocalAuthor> authors =
+							identityManager.getLocalAuthors();
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Loading authors took " + duration + " ms");
+					displayLocalAuthors(authors);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				}
+			}
+		});
+	}
+
+	private void displayLocalAuthors(final Collection<LocalAuthor> authors) {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				adapter.clear();
+				for (LocalAuthor a : authors)
+					adapter.add(new LocalAuthorItem(a));
+				adapter.sort(LocalAuthorItemComparator.INSTANCE);
+				// If a local author has been selected, select it again
+				if (localAuthorId == null) return;
+				int count = adapter.getCount();
+				for (int i = 0; i < count; i++) {
+					LocalAuthorItem item = adapter.getItem(i);
+					if (item == NEW) continue;
+					if (item.getLocalAuthor().getId().equals(localAuthorId)) {
+						spinner.setSelection(i);
+						return;
+					}
+				}
+			}
+		});
+	}
+
+	private void setLocalAuthorId(AuthorId authorId) {
+		localAuthorId = authorId;
+		button.setEnabled(localAuthorId != null);
+	}
+
+	public void onItemSelected(AdapterView<?> parent, View view, int position,
+			long id) {
+		LocalAuthorItem item = adapter.getItem(position);
+		if (item == NEW) {
+			setLocalAuthorId(null);
+			Intent i = new Intent(getActivity(), CreateIdentityActivity.class);
+			startActivityForResult(i, REQUEST_CREATE_IDENTITY);
+		} else {
+			setLocalAuthorId(item.getLocalAuthor().getId());
+		}
+	}
+
+	public void onNothingSelected(AdapterView<?> parent) {
+		setLocalAuthorId(null);
+	}
+
+	@Override
+	public void onActivityResult(int request, int result, Intent data) {
+		if (request == REQUEST_CREATE_IDENTITY && result == RESULT_OK) {
+			byte[] b = data.getByteArrayExtra("briar.LOCAL_AUTHOR_ID");
+			if (b == null) throw new IllegalStateException();
+			setLocalAuthorId(new AuthorId(b));
+			loadLocalAuthors();
+		} else
+			super.onActivityResult(request, result, data);
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java b/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..185d3c0b1efc3d4f14daca2d1b312d417a79aadb
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java
@@ -0,0 +1,230 @@
+package org.briarproject.android.keyagreement;
+
+import android.os.Bundle;
+import android.support.v7.widget.Toolbar;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.BriarFragmentActivity;
+import org.briarproject.android.fragment.BaseFragment;
+import org.briarproject.android.util.CustomAnimations;
+import org.briarproject.api.contact.ContactExchangeListener;
+import org.briarproject.api.contact.ContactExchangeTask;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.EventListener;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.keyagreement.KeyAgreementResult;
+import org.briarproject.api.settings.SettingsManager;
+
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.WARNING;
+
+public class KeyAgreementActivity extends BriarFragmentActivity implements
+		BaseFragment.BaseFragmentListener,
+		ChooseIdentityFragment.IdentitySelectedListener, EventListener,
+		ContactExchangeListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementActivity.class.getName());
+
+	private static final String LOCAL_AUTHOR_ID = "briar.LOCAL_AUTHOR_ID";
+
+	private static final int STEP_ID = 1;
+	private static final int STEP_QR = 2;
+	private static final int STEPS = 2;
+
+	@Inject
+	protected EventBus eventBus;
+	@Inject
+	protected SettingsManager settingsManager;
+
+	private Toolbar toolbar;
+	private View progressContainer;
+	private TextView progressTitle;
+
+	private AuthorId localAuthorId;
+
+	@Inject
+	protected volatile ContactExchangeTask contactExchangeTask;
+	@Inject
+	protected volatile IdentityManager identityManager;
+
+	@Override
+	public void injectActivity(AndroidComponent component) {
+		component.inject(this);
+	}
+
+	@SuppressWarnings("ConstantConditions")
+	@Override
+	public void onCreate(Bundle state) {
+		super.onCreate(state);
+		setContentView(R.layout.activity_with_loading);
+
+		toolbar = (Toolbar) findViewById(R.id.toolbar);
+		progressContainer = findViewById(R.id.container_progress);
+		progressTitle = (TextView) findViewById(R.id.title_progress_bar);
+
+		setSupportActionBar(toolbar);
+		getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+		if (state != null) {
+			byte[] b = state.getByteArray(LOCAL_AUTHOR_ID);
+			if (b != null)
+				localAuthorId = new AuthorId(b);
+		}
+
+		showStep(localAuthorId == null ? STEP_ID : STEP_QR);
+	}
+
+	@SuppressWarnings("ConstantConditions")
+	private void showStep(int step) {
+		getSupportActionBar().setTitle(
+				String.format(getString(R.string.add_contact_title_step), step,
+						STEPS));
+		switch (step) {
+			case STEP_QR:
+				startFragment(ShowQrCodeFragment.newInstance());
+				break;
+			case STEP_ID:
+			default:
+				startFragment(ChooseIdentityFragment.newInstance());
+				break;
+		}
+	}
+
+	@Override
+	public void onResume() {
+		super.onResume();
+		eventBus.addListener(this);
+	}
+
+	@Override
+	protected void onPause() {
+		super.onPause();
+		eventBus.removeListener(this);
+	}
+
+	@Override
+	public void onSaveInstanceState(Bundle state) {
+		super.onSaveInstanceState(state);
+		if (localAuthorId != null) {
+			byte[] b = localAuthorId.getBytes();
+			state.putByteArray(LOCAL_AUTHOR_ID, b);
+		}
+	}
+
+	@Override
+	public void showLoadingScreen(boolean isBlocking, int stringId) {
+		if (isBlocking) {
+			CustomAnimations.animateHeight(toolbar, false, 250);
+		}
+		progressTitle.setText(stringId);
+		progressContainer.setVisibility(View.VISIBLE);
+	}
+
+	@Override
+	public void hideLoadingScreen() {
+		CustomAnimations.animateHeight(toolbar, true, 250);
+		progressContainer.setVisibility(View.INVISIBLE);
+	}
+
+	@Override
+	public void identitySelected(AuthorId localAuthorId) {
+		this.localAuthorId = localAuthorId;
+		showStep(STEP_QR);
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementFinishedEvent) {
+			KeyAgreementFinishedEvent event = (KeyAgreementFinishedEvent) e;
+			keyAgreementFinished(event.getResult());
+		}
+	}
+
+	private void keyAgreementFinished(final KeyAgreementResult result) {
+		runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				showLoadingScreen(false, R.string.exchanging_contact_details);
+				startContactExchange(result);
+			}
+		});
+	}
+
+	private void startContactExchange(final KeyAgreementResult result) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				LocalAuthor localAuthor;
+				// Load the local pseudonym
+				try {
+					localAuthor = identityManager.getLocalAuthor(localAuthorId);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					contactExchangeFailed();
+					return;
+				}
+
+				// Exchange contact details
+				contactExchangeTask.startExchange(KeyAgreementActivity.this,
+						localAuthor, result.getMasterKey(),
+						result.getConnection(), result.getTransportId(),
+						result.wasAlice());
+			}
+		});
+	}
+
+	@Override
+	public void contactExchangeSucceeded(final Author remoteAuthor) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				String contactName = remoteAuthor.getName();
+				String format = getString(R.string.contact_added_toast);
+				String text = String.format(format, contactName);
+				Toast.makeText(KeyAgreementActivity.this, text, LENGTH_LONG)
+						.show();
+				finish();
+			}
+		});
+	}
+
+	@Override
+	public void duplicateContact(final Author remoteAuthor) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				String contactName = remoteAuthor.getName();
+				String format = getString(R.string.contact_already_exists);
+				String text = String.format(format, contactName);
+				Toast.makeText(KeyAgreementActivity.this, text, LENGTH_LONG)
+						.show();
+				finish();
+			}
+		});
+	}
+
+	@Override
+	public void contactExchangeFailed() {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				Toast.makeText(KeyAgreementActivity.this,
+						R.string.contact_exchange_failed, LENGTH_LONG).show();
+				finish();
+			}
+		});
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..df18a12fce80d8cf024b0599269ed2e6f4470460
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java
@@ -0,0 +1,371 @@
+package org.briarproject.android.keyagreement;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.Camera;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.util.Base64;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.zxing.Result;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.fragment.BaseEventFragment;
+import org.briarproject.android.util.AndroidUtils;
+import org.briarproject.android.util.CameraView;
+import org.briarproject.android.util.QrCodeDecoder;
+import org.briarproject.android.util.QrCodeUtils;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.KeyAgreementAbortedEvent;
+import org.briarproject.api.event.KeyAgreementFailedEvent;
+import org.briarproject.api.event.KeyAgreementListeningEvent;
+import org.briarproject.api.event.KeyAgreementStartedEvent;
+import org.briarproject.api.event.KeyAgreementWaitingEvent;
+import org.briarproject.api.keyagreement.KeyAgreementTask;
+import org.briarproject.api.keyagreement.KeyAgreementTaskFactory;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.keyagreement.PayloadParser;
+
+import java.io.IOException;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED;
+import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static android.widget.LinearLayout.HORIZONTAL;
+import static android.widget.LinearLayout.VERTICAL;
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.WARNING;
+
+@SuppressWarnings("deprecation")
+public class ShowQrCodeFragment extends BaseEventFragment
+		implements QrCodeDecoder.ResultCallback {
+
+	private static final Logger LOG =
+			Logger.getLogger(ShowQrCodeFragment.class.getName());
+
+	public static final String TAG = "ShowQrCodeFragment";
+
+	@Inject
+	protected KeyAgreementTaskFactory keyAgreementTaskFactory;
+	@Inject
+	protected PayloadEncoder payloadEncoder;
+	@Inject
+	protected PayloadParser payloadParser;
+
+	private LinearLayout qrLayout;
+	private CameraView cameraView;
+	private TextView status;
+	private ImageView qrCode;
+
+	private BluetoothStateReceiver receiver;
+	private QrCodeDecoder decoder;
+	private boolean gotRemotePayload;
+
+	private volatile KeyAgreementTask task;
+	private volatile BluetoothAdapter adapter;
+	private volatile boolean waitingForBluetooth;
+
+	public static ShowQrCodeFragment newInstance() {
+		Bundle args = new Bundle();
+		ShowQrCodeFragment fragment = new ShowQrCodeFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Override
+	public void injectActivity(AndroidComponent component) {
+		component.inject(this);
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater, ViewGroup container,
+			Bundle savedInstanceState) {
+		return inflater.inflate(R.layout.fragment_keyagreement_qr, container,
+				false);
+	}
+
+	@Override
+	public void onViewCreated(View view, Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+
+		qrLayout = (LinearLayout) view.findViewById(R.id.qr_layout);
+		cameraView = (CameraView) view.findViewById(R.id.camera_view);
+		status = (TextView) view.findViewById(R.id.connect_status);
+		qrCode = (ImageView) view.findViewById(R.id.qr_code);
+	}
+
+	@Override
+	public void onActivityCreated(Bundle savedInstanceState) {
+		super.onActivityCreated(savedInstanceState);
+
+		getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR);
+
+		decoder = new QrCodeDecoder(this);
+
+		Display display = getActivity().getWindowManager().getDefaultDisplay();
+		boolean portrait = display.getWidth() < display.getHeight();
+		qrLayout.setOrientation(portrait ? VERTICAL : HORIZONTAL);
+
+		adapter = BluetoothAdapter.getDefaultAdapter();
+	}
+
+	@Override
+	public void onStart() {
+		super.onStart();
+
+		// Listen for changes to the Bluetooth state
+		IntentFilter filter = new IntentFilter();
+		filter.addAction(ACTION_STATE_CHANGED);
+		receiver = new BluetoothStateReceiver();
+		getActivity().registerReceiver(receiver, filter);
+
+		// Enable BT adapter if it is not already on.
+		if (adapter != null && !adapter.isEnabled()) {
+			waitingForBluetooth = true;
+			AndroidUtils.enableBluetooth(adapter, true);
+		} else
+			startListening();
+	}
+
+	@Override
+	public void onResume() {
+		super.onResume();
+		if (!gotRemotePayload) openCamera();
+	}
+
+	@Override
+	public void onPause() {
+		super.onPause();
+		if (!gotRemotePayload) releaseCamera();
+	}
+
+	@Override
+	public void onStop() {
+		super.onStop();
+		stopListening();
+		if (receiver != null) getActivity().unregisterReceiver(receiver);
+	}
+
+	private void startListening() {
+		task = keyAgreementTaskFactory.getTask();
+		gotRemotePayload = false;
+		new Thread(new Runnable() {
+			@Override
+			public void run() {
+				task.listen();
+			}
+		}).start();
+	}
+
+	private void stopListening() {
+		new Thread(new Runnable() {
+			@Override
+			public void run() {
+				task.stopListening();
+			}
+		}).start();
+	}
+
+	private void openCamera() {
+		AsyncTask<Void, Void, Camera> openTask =
+				new AsyncTask<Void, Void, Camera>() {
+					@Override
+					protected Camera doInBackground(Void... unused) {
+						LOG.info("Opening camera");
+						try {
+							return Camera.open();
+						} catch (RuntimeException e) {
+							LOG.log(WARNING,
+									"Error opening camera, trying again", e);
+							try {
+								Thread.sleep(1000);
+							} catch (InterruptedException e2) {
+								LOG.info("Interrupted before second attempt");
+								return null;
+							}
+							try {
+								return Camera.open();
+							} catch (RuntimeException e2) {
+								LOG.log(WARNING, "Error opening camera", e2);
+								return null;
+							}
+						}
+					}
+
+					@Override
+					protected void onPostExecute(Camera camera) {
+						if (camera == null) {
+							// TODO better solution?
+							getActivity().finish();
+						} else {
+							cameraView.start(camera, decoder, 0);
+						}
+					}
+				};
+		openTask.execute();
+	}
+
+	private void releaseCamera() {
+		LOG.info("Releasing camera");
+		try {
+			cameraView.stop();
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error releasing camera", e);
+			// TODO better solution
+			getActivity().finish();
+		}
+	}
+
+	private void reset() {
+		cameraView.setVisibility(View.VISIBLE);
+		startListening();
+		openCamera();
+	}
+
+	private void qrCodeScanned(String content) {
+		try {
+			// TODO use Base32
+			Payload remotePayload = payloadParser.parse(
+					Base64.decode(content, 0));
+			cameraView.setVisibility(View.GONE);
+			status.setText(R.string.connecting_to_device);
+			task.connectAndRunProtocol(remotePayload);
+		} catch (IOException e) {
+			// TODO show failure
+			Toast.makeText(getActivity(), R.string.qr_code_invalid,
+					LENGTH_LONG).show();
+		}
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementListeningEvent) {
+			KeyAgreementListeningEvent event = (KeyAgreementListeningEvent) e;
+			setQrCode(event.getLocalPayload());
+		} else if (e instanceof KeyAgreementFailedEvent) {
+			keyAgreementFailed();
+		} else if (e instanceof KeyAgreementWaitingEvent) {
+			keyAgreementWaiting();
+		} else if (e instanceof KeyAgreementStartedEvent) {
+			keyAgreementStarted();
+		} else if (e instanceof KeyAgreementAbortedEvent) {
+			KeyAgreementAbortedEvent event = (KeyAgreementAbortedEvent) e;
+			keyAgreementAborted(event.didRemoteAbort());
+		}
+	}
+
+	private void setQrCode(final Payload localPayload) {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				// TODO use Base32
+				String input = Base64.encodeToString(
+						payloadEncoder.encode(localPayload), 0);
+				qrCode.setImageBitmap(
+						QrCodeUtils.createQrCode(getActivity(), input));
+				// Simple fade-in animation
+				AlphaAnimation anim = new AlphaAnimation(0.0f, 1.0f);
+				anim.setDuration(200);
+				qrCode.startAnimation(anim);
+			}
+		});
+	}
+
+	private void keyAgreementFailed() {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				reset();
+				// TODO show failure somewhere persistent?
+				Toast.makeText(getActivity(), R.string.connection_failed,
+						LENGTH_LONG).show();
+			}
+		});
+	}
+
+	private void keyAgreementWaiting() {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				status.setText(R.string.waiting_for_contact);
+			}
+		});
+	}
+
+	private void keyAgreementStarted() {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				listener.showLoadingScreen(false,
+						R.string.authenticating_with_device);
+			}
+		});
+	}
+
+	private void keyAgreementAborted(final boolean remoteAborted) {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				reset();
+				listener.hideLoadingScreen();
+				// TODO show abort somewhere persistent?
+				Toast.makeText(getActivity(),
+						remoteAborted ? R.string.connection_aborted_remote :
+								R.string.connection_aborted_local, LENGTH_LONG)
+						.show();
+			}
+		});
+	}
+
+	@Override
+	public void handleResult(final Result result) {
+		listener.runOnUiThread(new Runnable() {
+			public void run() {
+				LOG.info("Got result from decoder");
+				if (!gotRemotePayload) {
+					gotRemotePayload = true;
+					releaseCamera();
+					qrCodeScanned(result.getText());
+				}
+			}
+		});
+	}
+
+	private class BluetoothStateReceiver extends BroadcastReceiver {
+
+		@Override
+		public void onReceive(Context ctx, Intent intent) {
+			int state = intent.getIntExtra(EXTRA_STATE, 0);
+			if (state == STATE_ON && waitingForBluetooth) {
+				LOG.info("Bluetooth enabled");
+				waitingForBluetooth = false;
+				startListening();
+			}
+		}
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/util/CameraView.java b/briar-android/src/org/briarproject/android/util/CameraView.java
new file mode 100644
index 0000000000000000000000000000000000000000..623650e6625fd13ea8e0c0a520ebb3ec0313948e
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/CameraView.java
@@ -0,0 +1,268 @@
+package org.briarproject.android.util;
+
+import android.content.Context;
+import android.hardware.Camera;
+import android.hardware.Camera.AutoFocusCallback;
+import android.hardware.Camera.CameraInfo;
+import android.hardware.Camera.Parameters;
+import android.hardware.Camera.Size;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_AUTO;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_EDOF;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_FIXED;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_MACRO;
+import static android.hardware.Camera.Parameters.SCENE_MODE_BARCODE;
+import static android.view.SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+
+@SuppressWarnings("deprecation")
+public class CameraView extends SurfaceView implements SurfaceHolder.Callback,
+		AutoFocusCallback {
+
+	private static final int AUTO_FOCUS_RETRY_DELAY = 5000; // Milliseconds
+	private static final Logger LOG =
+			Logger.getLogger(CameraView.class.getName());
+
+	private Camera camera = null;
+	private PreviewConsumer previewConsumer = null;
+	private int displayOrientation = 0, surfaceWidth = 0, surfaceHeight = 0;
+	private boolean autoFocus = false, surfaceExists = false;
+
+	public CameraView(Context context) {
+		super(context);
+		initialize();
+	}
+
+	public CameraView(Context context, AttributeSet attrs) {
+		super(context, attrs);
+		initialize();
+	}
+
+	public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
+		super(context, attrs, defStyleAttr);
+		initialize();
+	}
+
+	private void initialize() {
+		setKeepScreenOn(true);
+		SurfaceHolder holder = getHolder();
+		if (Build.VERSION.SDK_INT < 11)
+			holder.setType(SURFACE_TYPE_PUSH_BUFFERS);
+		holder.addCallback(this);
+	}
+
+	public void start(Camera camera, PreviewConsumer previewConsumer,
+			int rotationDegrees) {
+		this.camera = camera;
+		this.previewConsumer = previewConsumer;
+		setDisplayOrientation(rotationDegrees);
+		Parameters params = camera.getParameters();
+		setFocusMode(params);
+		setPreviewSize(params);
+		applyParameters(params);
+		if (surfaceExists) startPreview(getHolder());
+	}
+
+	public void stop() {
+		stopPreview();
+		try {
+			camera.release();
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error releasing camera", e);
+		}
+		camera = null;
+	}
+
+	private void startPreview(SurfaceHolder holder) {
+		try {
+			camera.setPreviewDisplay(holder);
+			camera.startPreview();
+			if (autoFocus) camera.autoFocus(this);
+			previewConsumer.start(camera);
+		} catch (IOException e) {
+			LOG.log(WARNING, "Error starting camera preview", e);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error starting camera preview", e);
+		}
+	}
+
+	private void stopPreview() {
+		try {
+			previewConsumer.stop();
+			if (autoFocus) camera.cancelAutoFocus();
+			camera.stopPreview();
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error stopping camera preview", e);
+		}
+	}
+
+	private void setDisplayOrientation(int rotationDegrees) {
+		int orientation;
+		CameraInfo info = new CameraInfo();
+		Camera.getCameraInfo(0, info);
+		if (info.facing == CAMERA_FACING_FRONT) {
+			orientation = (info.orientation + rotationDegrees) % 360;
+			orientation = (360 - orientation) % 360;
+		} else {
+			orientation = (info.orientation - rotationDegrees + 360) % 360;
+		}
+		if(LOG.isLoggable(INFO))
+			LOG.info("Display orientation " + orientation + " degrees");
+		try {
+			camera.setDisplayOrientation(orientation);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error setting display orientation", e);
+		}
+		displayOrientation = orientation;
+	}
+
+	private void setFocusMode(Parameters params) {
+		if (Build.VERSION.SDK_INT >= 15 &&
+				params.isVideoStabilizationSupported()) {
+			LOG.info("Enabling video stabilisation");
+			params.setVideoStabilization(true);
+		}
+		// This returns null on the HTC Wildfire S
+		List<String> sceneModes = params.getSupportedSceneModes();
+		if (sceneModes == null) sceneModes = Collections.emptyList();
+		List<String> focusModes = params.getSupportedFocusModes();
+		if (LOG.isLoggable(INFO)) {
+			LOG.info("Scene modes: " + sceneModes);
+			LOG.info("Focus modes: " + focusModes);
+		}
+		if (sceneModes.contains(SCENE_MODE_BARCODE)) {
+			LOG.info("Setting scene mode to barcode");
+			params.setSceneMode(SCENE_MODE_BARCODE);
+		} else if (Build.VERSION.SDK_INT >= 14 &&
+				focusModes.contains(FOCUS_MODE_CONTINUOUS_PICTURE)) {
+			LOG.info("Setting focus mode to continuous picture");
+			params.setFocusMode(FOCUS_MODE_CONTINUOUS_PICTURE);
+		} else if (focusModes.contains(FOCUS_MODE_CONTINUOUS_VIDEO)) {
+			LOG.info("Setting focus mode to continuous video");
+			params.setFocusMode(FOCUS_MODE_CONTINUOUS_VIDEO);
+		} else if (focusModes.contains(FOCUS_MODE_EDOF)) {
+			LOG.info("Setting focus mode to EDOF");
+			params.setFocusMode(FOCUS_MODE_EDOF);
+		} else if (focusModes.contains(FOCUS_MODE_MACRO)) {
+			LOG.info("Setting focus mode to macro");
+			params.setFocusMode(FOCUS_MODE_MACRO);
+			autoFocus = true;
+		} else if (focusModes.contains(FOCUS_MODE_AUTO)) {
+			LOG.info("Setting focus mode to auto");
+			params.setFocusMode(FOCUS_MODE_AUTO);
+			autoFocus = true;
+		} else if (focusModes.contains(FOCUS_MODE_FIXED)) {
+			LOG.info("Setting focus mode to fixed");
+			params.setFocusMode(FOCUS_MODE_FIXED);
+		} else {
+			LOG.info("No suitable focus mode");
+		}
+		params.setZoom(0);
+	}
+
+	private void setPreviewSize(Parameters params) {
+		if (surfaceWidth == 0 || surfaceHeight == 0) return;
+		float idealRatio = (float) surfaceWidth / surfaceHeight;
+		DisplayMetrics screen = getContext().getResources().getDisplayMetrics();
+		int screenMax = Math.max(screen.widthPixels, screen.heightPixels);
+		boolean rotatePreview = displayOrientation % 180 == 90;
+		List<Size> sizes = params.getSupportedPreviewSizes();
+		Size bestSize = null;
+		float bestScore = 0;
+		for (Size size : sizes) {
+			int width = rotatePreview ? size.height : size.width;
+			int height = rotatePreview ? size.width : size.height;
+			float ratio = (float) width / height;
+			float stretch = Math.max(ratio / idealRatio, idealRatio / ratio);
+			int pixels = width * height;
+			float score = width * height / stretch;
+			if (LOG.isLoggable(INFO)) {
+				LOG.info("Size " + size.width + "x" + size.height
+						+ ", stretch " + stretch + ", pixels " + pixels
+						+ ", score " + score);
+			}
+			// Large preview sizes can crash older devices
+			int maxDimension = Math.max(width, height);
+			if (Build.VERSION.SDK_INT < 14 && maxDimension > screenMax) {
+				LOG.info("Too large for screen");
+				continue;
+			}
+			if (bestSize == null || score > bestScore) {
+				bestSize = size;
+				bestScore = score;
+			}
+		}
+		if (bestSize != null) {
+			if(LOG.isLoggable(INFO))
+				LOG.info("Best size " + bestSize.width + "x" + bestSize.height);
+			params.setPreviewSize(bestSize.width, bestSize.height);
+		}
+	}
+
+	private void applyParameters(Parameters params) {
+		try {
+			camera.setParameters(params);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error setting camera parameters", e);
+		}
+	}
+
+	public void surfaceCreated(SurfaceHolder holder) {
+		LOG.info("Surface created");
+		surfaceExists = true;
+		if (camera != null) startPreview(holder);
+	}
+
+	public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
+		if (LOG.isLoggable(INFO)) LOG.info("Surface changed: " + w + "x" + h);
+		surfaceWidth = w;
+		surfaceHeight = h;
+		if (camera == null) return; // We are stopped
+		stopPreview();
+		try {
+			Parameters params = camera.getParameters();
+			setPreviewSize(params);
+			applyParameters(params);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error getting camera parameters", e);
+		}
+		startPreview(holder);
+	}
+
+	public void surfaceDestroyed(SurfaceHolder holder) {
+		LOG.info("Surface destroyed");
+		surfaceExists = false;
+		holder.removeCallback(this);
+	}
+
+	public void onAutoFocus(boolean success, final Camera camera) {
+		LOG.info("Auto focus succeeded: " + success);
+		postDelayed(new Runnable() {
+			public void run() {
+				retryAutoFocus();
+			}
+		}, AUTO_FOCUS_RETRY_DELAY);
+	}
+
+	private void retryAutoFocus() {
+		try {
+			if (camera != null) camera.autoFocus(this);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error retrying auto focus", e);
+		}
+	}
+}
\ No newline at end of file
diff --git a/briar-android/src/org/briarproject/android/util/PreviewConsumer.java b/briar-android/src/org/briarproject/android/util/PreviewConsumer.java
new file mode 100644
index 0000000000000000000000000000000000000000..e2fb6eed9556dc44bcb79246f9aaf6179ac112f9
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/PreviewConsumer.java
@@ -0,0 +1,11 @@
+package org.briarproject.android.util;
+
+import android.hardware.Camera;
+
+@SuppressWarnings("deprecation")
+public interface PreviewConsumer {
+
+	void start(Camera camera);
+
+	void stop();
+}
diff --git a/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..5fdc54c39478a45b8c56ccd68aa8296406092252
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java
@@ -0,0 +1,100 @@
+package org.briarproject.android.util;
+
+import android.hardware.Camera;
+import android.hardware.Camera.PreviewCallback;
+import android.hardware.Camera.Size;
+import android.os.AsyncTask;
+
+import com.google.zxing.BinaryBitmap;
+import com.google.zxing.LuminanceSource;
+import com.google.zxing.PlanarYUVLuminanceSource;
+import com.google.zxing.Reader;
+import com.google.zxing.ReaderException;
+import com.google.zxing.Result;
+import com.google.zxing.common.HybridBinarizer;
+import com.google.zxing.qrcode.QRCodeReader;
+
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.INFO;
+
+@SuppressWarnings("deprecation")
+public class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
+
+	private static final Logger LOG =
+			Logger.getLogger(QrCodeDecoder.class.getName());
+
+	private final Reader reader = new QRCodeReader();
+	private final ResultCallback callback;
+
+	private boolean stopped = false;
+
+	public QrCodeDecoder(ResultCallback callback) {
+		this.callback = callback;
+	}
+
+	public void start(Camera camera) {
+		stopped = false;
+		askForPreviewFrame(camera);
+	}
+
+	public void stop() {
+		stopped = true;
+	}
+
+	private void askForPreviewFrame(Camera camera) {
+		if (!stopped) camera.setOneShotPreviewCallback(this);
+	}
+
+	public void onPreviewFrame(byte[] data, Camera camera) {
+		if (!stopped) {
+			Size size = camera.getParameters().getPreviewSize();
+			new DecoderTask(camera, data, size.width, size.height).execute();
+		}
+	}
+
+	private class DecoderTask extends AsyncTask<Void, Void, Void> {
+
+		final Camera camera;
+		final byte[] data;
+		final int width, height;
+
+		DecoderTask(Camera camera, byte[] data, int width, int height) {
+			this.camera = camera;
+			this.data = data;
+			this.width = width;
+			this.height = height;
+		}
+
+		@Override
+		protected Void doInBackground(Void... params) {
+			long now = System.currentTimeMillis();
+			LuminanceSource src = new PlanarYUVLuminanceSource(data, width,
+					height, 0, 0, width, height, false);
+			BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(src));
+			Result result = null;
+			try {
+				result = reader.decode(bitmap);
+			} catch (ReaderException e) {
+				return null; // No barcode found
+			} finally {
+				reader.reset();
+			}
+			long duration = System.currentTimeMillis() - now;
+			if (LOG.isLoggable(INFO))
+				LOG.info("Decoding barcode took " + duration + " ms");
+			callback.handleResult(result);
+			return null;
+		}
+
+		@Override
+		protected void onPostExecute(Void result) {
+			askForPreviewFrame(camera);
+		}
+	}
+
+	public interface ResultCallback {
+
+		void handleResult(Result result);
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/util/QrCodeUtils.java b/briar-android/src/org/briarproject/android/util/QrCodeUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..ef8ff6ec6714e4c6a8f444d6c26a011053a9f21d
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/QrCodeUtils.java
@@ -0,0 +1,51 @@
+package org.briarproject.android.util;
+
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.util.DisplayMetrics;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+
+public class QrCodeUtils {
+
+	private static final Logger LOG =
+			Logger.getLogger(QrCodeUtils.class.getName());
+
+	public static Bitmap createQrCode(Activity activity, String input) {
+		// Get narrowest screen dimension
+		DisplayMetrics dm = new DisplayMetrics();
+		activity.getWindowManager().getDefaultDisplay().getMetrics(dm);
+		int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels);
+		try {
+			// Generate QR code
+			final BitMatrix encoded = new QRCodeWriter().encode(
+					input, BarcodeFormat.QR_CODE, smallestDimen, smallestDimen);
+			// Convert QR code to Bitmap
+			int width = encoded.getWidth();
+			int height = encoded.getHeight();
+			int[] pixels = new int[width * height];
+			for (int x = 0; x < width; x++) {
+				for (int y = 0; y < height; y++) {
+					pixels[y * width + x] =
+							encoded.get(x, y) ? Color.BLACK : Color.WHITE;
+				}
+			}
+			Bitmap qr = Bitmap.createBitmap(width, height,
+					Bitmap.Config.ARGB_8888);
+			qr.setPixels(pixels, 0, width, 0, 0, width, height);
+			return qr;
+		} catch (WriterException e) {
+			if (LOG.isLoggable(WARNING))
+				LOG.log(WARNING, e.toString(), e);
+			return null;
+		}
+	}
+}
diff --git a/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java b/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java
index 2bc7dbe702d2ddbeba33e540373dd5345a46d398..40a8a041063d78cdd1403c99e99a1603b8dea026 100644
--- a/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java
+++ b/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java
@@ -14,6 +14,9 @@ import org.briarproject.api.TransportId;
 import org.briarproject.android.api.AndroidExecutor;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.PseudoRandom;
+import org.briarproject.api.keyagreement.KeyAgreementConnection;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.TransportDescriptor;
 import org.briarproject.api.plugins.Backoff;
 import org.briarproject.api.plugins.duplex.DuplexPlugin;
 import org.briarproject.api.plugins.duplex.DuplexPluginCallback;
@@ -67,6 +70,9 @@ class DroidtoothPlugin implements DuplexPlugin {
 	private static final String DISCOVERY_FINISHED =
 			"android.bluetooth.adapter.action.DISCOVERY_FINISHED";
 
+	private static final String PROP_ADDRESS = "address";
+	private static final String PROP_UUID = "uuid";
+
 	private final Executor ioExecutor;
 	private final AndroidExecutor androidExecutor;
 	private final Context appContext;
@@ -161,7 +167,7 @@ class DroidtoothPlugin implements DuplexPlugin {
 				if (!StringUtils.isNullOrEmpty(address)) {
 					// Advertise the Bluetooth address to contacts
 					TransportProperties p = new TransportProperties();
-					p.put("address", address);
+					p.put(PROP_ADDRESS, address);
 					callback.mergeLocalProperties(p);
 				}
 				// Bind a server socket to accept connections from contacts
@@ -187,13 +193,13 @@ class DroidtoothPlugin implements DuplexPlugin {
 	}
 
 	private UUID getUuid() {
-		String uuid = callback.getLocalProperties().get("uuid");
+		String uuid = callback.getLocalProperties().get(PROP_UUID);
 		if (uuid == null) {
 			byte[] random = new byte[UUID_BYTES];
 			secureRandom.nextBytes(random);
 			uuid = UUID.nameUUIDFromBytes(random).toString();
 			TransportProperties p = new TransportProperties();
-			p.put("uuid", uuid);
+			p.put(PROP_UUID, uuid);
 			callback.mergeLocalProperties(p);
 		}
 		return UUID.fromString(uuid);
@@ -264,9 +270,9 @@ class DroidtoothPlugin implements DuplexPlugin {
 		for (Entry<ContactId, TransportProperties> e : remote.entrySet()) {
 			final ContactId c = e.getKey();
 			if (connected.contains(c)) continue;
-			final String address = e.getValue().get("address");
+			final String address = e.getValue().get(PROP_ADDRESS);
 			if (StringUtils.isNullOrEmpty(address)) continue;
-			final String uuid = e.getValue().get("uuid");
+			final String uuid = e.getValue().get(PROP_UUID);
 			if (StringUtils.isNullOrEmpty(uuid)) continue;
 			ioExecutor.execute(new Runnable() {
 				public void run() {
@@ -325,9 +331,9 @@ class DroidtoothPlugin implements DuplexPlugin {
 		if (!isRunning()) return null;
 		TransportProperties p = callback.getRemoteProperties().get(c);
 		if (p == null) return null;
-		String address = p.get("address");
+		String address = p.get(PROP_ADDRESS);
 		if (StringUtils.isNullOrEmpty(address)) return null;
-		String uuid = p.get("uuid");
+		String uuid = p.get(PROP_UUID);
 		if (StringUtils.isNullOrEmpty(uuid)) return null;
 		BluetoothSocket s = connect(address, uuid);
 		if (s == null) return null;
@@ -417,6 +423,48 @@ class DroidtoothPlugin implements DuplexPlugin {
 		});
 	}
 
+	public boolean supportsKeyAgreement() {
+		return true;
+	}
+
+	public KeyAgreementListener createKeyAgreementListener(
+			byte[] localCommitment) {
+		// No truncation necessary because COMMIT_LENGTH = 16
+		UUID uuid = UUID.nameUUIDFromBytes(localCommitment);
+		if (LOG.isLoggable(INFO)) LOG.info("Key agreement UUID " + uuid);
+		// Bind a server socket for receiving invitation connections
+		BluetoothServerSocket ss;
+		try {
+			ss = InsecureBluetooth.listen(adapter, "RFCOMM", uuid);
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			return null;
+		}
+		TransportProperties p = new TransportProperties();
+		String address = AndroidUtils.getBluetoothAddress(appContext, adapter);
+		if (!StringUtils.isNullOrEmpty(address))
+			p.put(PROP_ADDRESS, address);
+		TransportDescriptor d = new TransportDescriptor(ID, p);
+		return new BluetoothKeyAgreementListener(d, ss);
+	}
+
+	public DuplexTransportConnection createKeyAgreementConnection(
+			byte[] remoteCommitment, TransportDescriptor d, long timeout) {
+		if (!isRunning()) return null;
+		if (!ID.equals(d.getIdentifier())) return null;
+		TransportProperties p = d.getProperties();
+		if (p == null) return null;
+		String address = p.get(PROP_ADDRESS);
+		if (StringUtils.isNullOrEmpty(address)) return null;
+		// No truncation necessary because COMMIT_LENGTH = 16
+		UUID uuid = UUID.nameUUIDFromBytes(remoteCommitment);
+		if (LOG.isLoggable(INFO))
+			LOG.info("Connecting to key agreement UUID " + uuid);
+		BluetoothSocket s = connect(address, uuid.toString());
+		if (s == null) return null;
+		return new DroidtoothTransportConnection(this, s);
+	}
+
 	private class BluetoothStateReceiver extends BroadcastReceiver {
 
 		@Override
@@ -545,4 +593,39 @@ class DroidtoothPlugin implements DuplexPlugin {
 			return s;
 		}
 	}
+
+	private class BluetoothKeyAgreementListener extends KeyAgreementListener {
+
+		private final BluetoothServerSocket ss;
+
+		public BluetoothKeyAgreementListener(TransportDescriptor descriptor,
+				BluetoothServerSocket ss) {
+			super(descriptor);
+			this.ss = ss;
+		}
+
+		@Override
+		public Callable<KeyAgreementConnection> listen() {
+			return new Callable<KeyAgreementConnection>() {
+				@Override
+				public KeyAgreementConnection call() throws IOException {
+					BluetoothSocket s = ss.accept();
+					if (LOG.isLoggable(INFO))
+						LOG.info(ID.getString() + ": Incoming connection");
+					return new KeyAgreementConnection(
+							new DroidtoothTransportConnection(
+									DroidtoothPlugin.this, s), ID);
+				}
+			};
+		}
+
+		@Override
+		public void close() {
+			try {
+				ss.close();
+			} catch (IOException e) {
+				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			}
+		}
+	}
 }
diff --git a/briar-android/src/org/briarproject/plugins/tor/TorPlugin.java b/briar-android/src/org/briarproject/plugins/tor/TorPlugin.java
index e5f7180c0e9f91ff459fb941bbc0c2d877bc461b..d3dbff7932243505f43d9426debf3bcec0f597b4 100644
--- a/briar-android/src/org/briarproject/plugins/tor/TorPlugin.java
+++ b/briar-android/src/org/briarproject/plugins/tor/TorPlugin.java
@@ -19,6 +19,8 @@ import org.briarproject.api.crypto.PseudoRandom;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventListener;
 import org.briarproject.api.event.SettingsUpdatedEvent;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.TransportDescriptor;
 import org.briarproject.api.plugins.duplex.DuplexPlugin;
 import org.briarproject.api.plugins.duplex.DuplexPluginCallback;
 import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
@@ -570,6 +572,20 @@ class TorPlugin implements DuplexPlugin, EventHandler,
 		throw new UnsupportedOperationException();
 	}
 
+	public boolean supportsKeyAgreement() {
+		return false;
+	}
+
+	public KeyAgreementListener createKeyAgreementListener(
+			byte[] commitment) {
+		throw new UnsupportedOperationException();
+	}
+
+	public DuplexTransportConnection createKeyAgreementConnection(
+			byte[] commitment, TransportDescriptor d, long timeout) {
+		throw new UnsupportedOperationException();
+	}
+
 	public void circuitStatus(String status, String id, String path) {
 		if (status.equals("BUILT") && !circuitBuilt.getAndSet(true)) {
 			LOG.info("First circuit built");
diff --git a/briar-api/src/org/briarproject/api/contact/ContactExchangeListener.java b/briar-api/src/org/briarproject/api/contact/ContactExchangeListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..50bf543c71c26607f6c041cbff59c23df152e85b
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/contact/ContactExchangeListener.java
@@ -0,0 +1,14 @@
+package org.briarproject.api.contact;
+
+import org.briarproject.api.identity.Author;
+
+public interface ContactExchangeListener {
+
+	void contactExchangeSucceeded(Author remoteAuthor);
+
+	/** The exchange failed because the contact already exists. */
+	void duplicateContact(Author remoteAuthor);
+
+	/** A general failure. */
+	void contactExchangeFailed();
+}
diff --git a/briar-api/src/org/briarproject/api/contact/ContactExchangeTask.java b/briar-api/src/org/briarproject/api/contact/ContactExchangeTask.java
new file mode 100644
index 0000000000000000000000000000000000000000..0d85850f6fa8a67b9401396854652ce3bec47c02
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/contact/ContactExchangeTask.java
@@ -0,0 +1,20 @@
+package org.briarproject.api.contact;
+
+import org.briarproject.api.TransportId;
+import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
+
+/**
+ * A task for conducting a contact information exchange with a remote peer.
+ */
+public interface ContactExchangeTask {
+
+	/**
+	 * Exchange contact information with a remote peer.
+	 */
+	void startExchange(ContactExchangeListener listener,
+			LocalAuthor localAuthor, SecretKey masterSecret,
+			DuplexTransportConnection conn, TransportId transportId,
+			boolean alice);
+}
diff --git a/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java b/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java
index 0ba779202b8d6eea5975a958a28cb22bbb42d215..11631f22fda6db0351682106a9f0828d2c02e20f 100644
--- a/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java
+++ b/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java
@@ -36,18 +36,17 @@ public interface CryptoComponent {
 	int deriveBTConfirmationCode(SecretKey master, boolean alice);
 
 	/**
-	 * Derives a header key for an invitation stream from the given master
-	 * secret.
+	 * Derives a stream header key from the given master secret.
 	 * @param alice whether the key is for use by Alice or Bob.
 	 */
-	SecretKey deriveBTInvitationKey(SecretKey master, boolean alice);
+	SecretKey deriveHeaderKey(SecretKey master, boolean alice);
 
 	/**
 	 * Derives a nonce from the given master secret for one of the parties to
 	 * sign.
 	 * @param alice whether the nonce is for use by Alice or Bob.
 	 */
-	byte[] deriveBTSignatureNonce(SecretKey master, boolean alice);
+	byte[] deriveSignatureNonce(SecretKey master, boolean alice);
 
 	/**
 	 * Derives a commitment to the provided public key.
@@ -107,7 +106,7 @@ public interface CryptoComponent {
 	 * Derives a master secret from two public keys and one of the corresponding
 	 * private keys.
 	 * <p/>
-	 * Part of BQP. This is a helper method that calls
+	 * This is a helper method that calls
 	 * deriveMasterSecret(deriveSharedSecret(theirPublicKey, ourKeyPair, alice))
 	 *
 	 * @param theirPublicKey the ephemeral public key of the remote party
diff --git a/briar-api/src/org/briarproject/api/event/KeyAgreementAbortedEvent.java b/briar-api/src/org/briarproject/api/event/KeyAgreementAbortedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..5e217f7f4d2752f09ae68d7e0ee9b9cbc68f9d46
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/KeyAgreementAbortedEvent.java
@@ -0,0 +1,15 @@
+package org.briarproject.api.event;
+
+/** An event that is broadcast when a BQP protocol aborts. */
+public class KeyAgreementAbortedEvent extends Event {
+
+	private final boolean remoteAborted;
+
+	public KeyAgreementAbortedEvent(boolean remoteAborted) {
+		this.remoteAborted = remoteAborted;
+	}
+
+	public boolean didRemoteAbort() {
+		return remoteAborted;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/event/KeyAgreementFailedEvent.java b/briar-api/src/org/briarproject/api/event/KeyAgreementFailedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..97313a276b0bf19f582e4177b3680753ac09568f
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/KeyAgreementFailedEvent.java
@@ -0,0 +1,6 @@
+package org.briarproject.api.event;
+
+/** An event that is broadcast when a BQP connection cannot be created. */
+public class KeyAgreementFailedEvent extends Event {
+
+}
diff --git a/briar-api/src/org/briarproject/api/event/KeyAgreementFinishedEvent.java b/briar-api/src/org/briarproject/api/event/KeyAgreementFinishedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..f2afe349ebb23c6a9c63d33d518660f7b84e036b
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/KeyAgreementFinishedEvent.java
@@ -0,0 +1,17 @@
+package org.briarproject.api.event;
+
+import org.briarproject.api.keyagreement.KeyAgreementResult;
+
+/** An event that is broadcast when a BQP protocol completes. */
+public class KeyAgreementFinishedEvent extends Event {
+
+	private final KeyAgreementResult result;
+
+	public KeyAgreementFinishedEvent(KeyAgreementResult result) {
+		this.result = result;
+	}
+
+	public KeyAgreementResult getResult() {
+		return result;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/event/KeyAgreementListeningEvent.java b/briar-api/src/org/briarproject/api/event/KeyAgreementListeningEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..df3b2cbac5fc6ced89e43a37cee90766a7a2e70b
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/KeyAgreementListeningEvent.java
@@ -0,0 +1,17 @@
+package org.briarproject.api.event;
+
+import org.briarproject.api.keyagreement.Payload;
+
+/** An event that is broadcast when a BQP task is listening. */
+public class KeyAgreementListeningEvent extends Event {
+
+	private final Payload localPayload;
+
+	public KeyAgreementListeningEvent(Payload localPayload) {
+		this.localPayload = localPayload;
+	}
+
+	public Payload getLocalPayload() {
+		return localPayload;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/event/KeyAgreementStartedEvent.java b/briar-api/src/org/briarproject/api/event/KeyAgreementStartedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..a12a9c459a38c3b1131bb605f1b9eb857a13d2f2
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/KeyAgreementStartedEvent.java
@@ -0,0 +1,6 @@
+package org.briarproject.api.event;
+
+/** An event that is broadcast when a BQP protocol completes. */
+public class KeyAgreementStartedEvent extends Event {
+
+}
diff --git a/briar-api/src/org/briarproject/api/event/KeyAgreementWaitingEvent.java b/briar-api/src/org/briarproject/api/event/KeyAgreementWaitingEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..473c6538adb053d4aeb0551621ebf8a8b2c97aa2
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/KeyAgreementWaitingEvent.java
@@ -0,0 +1,9 @@
+package org.briarproject.api.event;
+
+/**
+ * An event that is broadcast when a BQP protocol is waiting on the remote
+ * peer to start.
+ */
+public class KeyAgreementWaitingEvent extends Event {
+
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConnection.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConnection.java
new file mode 100644
index 0000000000000000000000000000000000000000..7dfa3bc689d4a288ce01b90de6e10f3e3bd50e7f
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConnection.java
@@ -0,0 +1,23 @@
+package org.briarproject.api.keyagreement;
+
+import org.briarproject.api.TransportId;
+import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
+
+public class KeyAgreementConnection {
+	private final DuplexTransportConnection conn;
+	private final TransportId id;
+
+	public KeyAgreementConnection(DuplexTransportConnection conn,
+			TransportId id) {
+		this.conn = conn;
+		this.id = id;
+	}
+
+	public DuplexTransportConnection getConnection() {
+		return conn;
+	}
+
+	public TransportId getTransportId() {
+		return id;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConstants.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConstants.java
index 521789244d9a8e61ae5926ea24509ebde82fbf4f..f239111269124e8d938f15bbc5561a4670ed5d0a 100644
--- a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConstants.java
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementConstants.java
@@ -3,6 +3,17 @@ package org.briarproject.api.keyagreement;
 
 public interface KeyAgreementConstants {
 
+	/** The current version of the BQP protocol. */
+	byte PROTOCOL_VERSION = 1;
+
+	/** The length of the record header in bytes. */
+	int RECORD_HEADER_LENGTH = 4;
+
+	/** The offset of the payload length in the record header, in bytes. */
+	int RECORD_HEADER_PAYLOAD_LENGTH_OFFSET = 2;
+
 	/** The length of the BQP key commitment in bytes. */
 	int COMMIT_LENGTH = 16;
+
+	long CONNECTION_TIMEOUT = 20 * 1000; // Milliseconds
 }
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementListener.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..05163614cc13d2ab383c1041813fc48b63977ae0
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementListener.java
@@ -0,0 +1,35 @@
+package org.briarproject.api.keyagreement;
+
+import java.util.concurrent.Callable;
+
+/**
+ * An class for managing a particular key agreement listener.
+ */
+public abstract class KeyAgreementListener {
+
+	private final TransportDescriptor descriptor;
+
+	public KeyAgreementListener(TransportDescriptor descriptor) {
+		this.descriptor = descriptor;
+	}
+
+	/**
+	 * Returns the descriptor that a remote peer can use to connect to this
+	 * listener.
+	 */
+	public TransportDescriptor getDescriptor() {
+		return descriptor;
+	}
+
+	/**
+	 * Starts listening for incoming connections, and returns a Callable that
+	 * will return a KeyAgreementConnection when an incoming connection is
+	 * received.
+	 */
+	public abstract Callable<KeyAgreementConnection> listen();
+
+	/**
+	 * Closes the underlying server socket.
+	 */
+	public abstract void close();
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementResult.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..3f772ca28792d58571a82b5da7b6b65a1ab27624
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementResult.java
@@ -0,0 +1,38 @@
+package org.briarproject.api.keyagreement;
+
+import org.briarproject.api.TransportId;
+import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
+
+public class KeyAgreementResult {
+
+	private final SecretKey masterKey;
+	private final DuplexTransportConnection connection;
+	private final TransportId transportId;
+	private final boolean alice;
+
+	public KeyAgreementResult(SecretKey masterKey,
+			DuplexTransportConnection connection, TransportId transportId,
+			boolean alice) {
+		this.masterKey = masterKey;
+		this.connection = connection;
+		this.transportId = transportId;
+		this.alice = alice;
+	}
+
+	public SecretKey getMasterKey() {
+		return masterKey;
+	}
+
+	public DuplexTransportConnection getConnection() {
+		return connection;
+	}
+
+	public TransportId getTransportId() {
+		return transportId;
+	}
+
+	public boolean wasAlice() {
+		return alice;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTask.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTask.java
new file mode 100644
index 0000000000000000000000000000000000000000..443ef42bd68d11f7bae75cf576d8e2721bc0a63f
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTask.java
@@ -0,0 +1,21 @@
+package org.briarproject.api.keyagreement;
+
+/** A task for conducting a key agreement with a remote peer. */
+public interface KeyAgreementTask {
+
+	/**
+	 * Start listening for short-range BQP connections, if we are not already.
+	 * <p/>
+	 * Will trigger a KeyAgreementListeningEvent containing the local Payload,
+	 * even if we are already listening.
+	 */
+	void listen();
+
+	/**
+	 * Stop listening for short-range BQP connections.
+	 */
+	void stopListening();
+
+	/** Asynchronously start the connection process. */
+	void connectAndRunProtocol(Payload remotePayload);
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTaskFactory.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTaskFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..f823fcacdbfe44898399ad5851f62a86c542e06e
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTaskFactory.java
@@ -0,0 +1,8 @@
+package org.briarproject.api.keyagreement;
+
+/** Manages tasks for conducting key agreements with remote peers. */
+public interface KeyAgreementTaskFactory {
+
+	/** Gets the current key agreement task. */
+	KeyAgreementTask getTask();
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTaskId.java b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTaskId.java
new file mode 100644
index 0000000000000000000000000000000000000000..434f9cb71ac2ea2bd746fd6dd225d294b1f854bb
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/KeyAgreementTaskId.java
@@ -0,0 +1,18 @@
+package org.briarproject.api.keyagreement;
+
+import org.briarproject.api.UniqueId;
+
+/**
+ * Type-safe wrapper for a byte array that uniquely identifies a BQP task.
+ */
+public class KeyAgreementTaskId extends UniqueId {
+
+	public KeyAgreementTaskId(byte[] id) {
+		super(id);
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof KeyAgreementTaskId && super.equals(o);
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/Payload.java b/briar-api/src/org/briarproject/api/keyagreement/Payload.java
new file mode 100644
index 0000000000000000000000000000000000000000..0c749da53ec9e2a70c652cc3dcdf70f5a9d079a6
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/Payload.java
@@ -0,0 +1,34 @@
+package org.briarproject.api.keyagreement;
+
+import org.briarproject.api.Bytes;
+
+import java.util.List;
+
+/**
+ * A BQP payload.
+ */
+public class Payload implements Comparable<Payload> {
+
+	private final Bytes commitment;
+	private final List<TransportDescriptor> descriptors;
+
+	public Payload(byte[] commitment, List<TransportDescriptor> descriptors) {
+		this.commitment = new Bytes(commitment);
+		this.descriptors = descriptors;
+	}
+
+	/** Returns the commitment contained in this payload. */
+	public byte[] getCommitment() {
+		return commitment.getBytes();
+	}
+
+	/** Returns the transport descriptors contained in this payload. */
+	public List<TransportDescriptor> getTransportDescriptors() {
+		return descriptors;
+	}
+
+	@Override
+	public int compareTo(Payload p) {
+		return commitment.compareTo(p.commitment);
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/PayloadEncoder.java b/briar-api/src/org/briarproject/api/keyagreement/PayloadEncoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..31876c10271146fe99de491bd6d3f1053ad769da
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/PayloadEncoder.java
@@ -0,0 +1,6 @@
+package org.briarproject.api.keyagreement;
+
+public interface PayloadEncoder {
+
+	byte[] encode(Payload p);
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/PayloadParser.java b/briar-api/src/org/briarproject/api/keyagreement/PayloadParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..0df9c653d418d464af843f74547981bc06f7a223
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/PayloadParser.java
@@ -0,0 +1,8 @@
+package org.briarproject.api.keyagreement;
+
+import java.io.IOException;
+
+public interface PayloadParser {
+
+	Payload parse(byte[] raw) throws IOException;
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/RecordTypes.java b/briar-api/src/org/briarproject/api/keyagreement/RecordTypes.java
new file mode 100644
index 0000000000000000000000000000000000000000..ef1d2ff51feb21d04b9e995474e091aede397379
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/RecordTypes.java
@@ -0,0 +1,9 @@
+package org.briarproject.api.keyagreement;
+
+/** Record types for BQP. */
+public interface RecordTypes {
+
+	byte KEY = 0;
+	byte CONFIRM = 1;
+	byte ABORT = 2;
+}
diff --git a/briar-api/src/org/briarproject/api/keyagreement/TransportDescriptor.java b/briar-api/src/org/briarproject/api/keyagreement/TransportDescriptor.java
new file mode 100644
index 0000000000000000000000000000000000000000..cdaa5a579464e7287f06ada392cb16d9b2ffcb5d
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/keyagreement/TransportDescriptor.java
@@ -0,0 +1,28 @@
+package org.briarproject.api.keyagreement;
+
+import org.briarproject.api.TransportId;
+import org.briarproject.api.properties.TransportProperties;
+
+/**
+ * Describes how to connect to a device over a short-range transport.
+ */
+public class TransportDescriptor {
+
+	private final TransportId id;
+	private final TransportProperties properties;
+
+	public TransportDescriptor(TransportId id, TransportProperties properties) {
+		this.id = id;
+		this.properties = properties;
+	}
+
+	/** Returns the transport identifier. */
+	public TransportId getIdentifier() {
+		return id;
+	}
+
+	/** Returns the transport properties. */
+	public TransportProperties getProperties() {
+		return properties;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/plugins/PluginManager.java b/briar-api/src/org/briarproject/api/plugins/PluginManager.java
index 35248962d65da3a6e6d2de481a21e4c08f874f7b..838c66ff86e1ac74a41c507f9d48b6774038c247 100644
--- a/briar-api/src/org/briarproject/api/plugins/PluginManager.java
+++ b/briar-api/src/org/briarproject/api/plugins/PluginManager.java
@@ -19,4 +19,7 @@ public interface PluginManager {
 
 	/** Returns any running duplex plugins that support invitations. */
 	Collection<DuplexPlugin> getInvitationPlugins();
+
+	/** Returns any running duplex plugins that support key agreement. */
+	Collection<DuplexPlugin> getKeyAgreementPlugins();
 }
diff --git a/briar-api/src/org/briarproject/api/plugins/duplex/DuplexPlugin.java b/briar-api/src/org/briarproject/api/plugins/duplex/DuplexPlugin.java
index 519400b5639fb2cc598dad8f88b16b7433853feb..ff7db3b51d78526d10b2a54bcc342c3a691583c2 100644
--- a/briar-api/src/org/briarproject/api/plugins/duplex/DuplexPlugin.java
+++ b/briar-api/src/org/briarproject/api/plugins/duplex/DuplexPlugin.java
@@ -2,6 +2,8 @@ package org.briarproject.api.plugins.duplex;
 
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.PseudoRandom;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.TransportDescriptor;
 import org.briarproject.api.plugins.Plugin;
 
 /** An interface for transport plugins that support duplex communication. */
@@ -24,4 +26,19 @@ public interface DuplexPlugin extends Plugin {
 	 */
 	DuplexTransportConnection createInvitationConnection(PseudoRandom r,
 			long timeout, boolean alice);
+
+	/** Returns true if the plugin supports short-range key agreement. */
+	boolean supportsKeyAgreement();
+
+	/**
+	 * Returns a listener that can be used to perform key agreement.
+	 */
+	KeyAgreementListener createKeyAgreementListener(byte[] localCommitment);
+
+	/**
+	 * Attempts to connect to the remote peer specified in the given descriptor.
+	 * Returns null if no connection can be established within the given time.
+	 */
+	DuplexTransportConnection createKeyAgreementConnection(
+			byte[] remoteCommitment, TransportDescriptor d, long timeout);
 }
diff --git a/briar-core/build.gradle b/briar-core/build.gradle
index 7fe4201a76c6f6e93d5f4aa4857015fcdf92e0cf..94452e1e08860a5dfc8fd53f3bc6b2e4515e7402 100644
--- a/briar-core/build.gradle
+++ b/briar-core/build.gradle
@@ -13,12 +13,14 @@ dependencies {
 	compile fileTree(dir: 'libs', include: '*.jar')
 	compile "com.madgag.spongycastle:core:1.54.0.0"
 	compile "com.h2database:h2:1.4.190"
+	compile "com.google.zxing:core:3.2.1"
 }
 
 dependencyVerification {
 	verify = [
 			'com.madgag.spongycastle:core:1e7fa4b19ccccd1011364ab838d0b4702470c178bbbdd94c5c90b2d4d749ea1e',
 			'com.h2database:h2:23ba495a07bbbb3bd6c3084d10a96dad7a23741b8b6d64b213459a784195a98c',
+			'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259',
 	]
 }
 
diff --git a/briar-core/src/org/briarproject/CoreModule.java b/briar-core/src/org/briarproject/CoreModule.java
index 11d6c87b0ac55d85e9a16c3e2de12a872c8b41d4..6303f210b3129e5a28e7121509aa423101c5c03c 100644
--- a/briar-core/src/org/briarproject/CoreModule.java
+++ b/briar-core/src/org/briarproject/CoreModule.java
@@ -9,6 +9,7 @@ import org.briarproject.event.EventModule;
 import org.briarproject.forum.ForumModule;
 import org.briarproject.identity.IdentityModule;
 import org.briarproject.invitation.InvitationModule;
+import org.briarproject.keyagreement.KeyAgreementModule;
 import org.briarproject.lifecycle.LifecycleModule;
 import org.briarproject.messaging.MessagingModule;
 import org.briarproject.plugins.PluginsModule;
@@ -23,7 +24,8 @@ import dagger.Module;
 
 @Module(includes = {DatabaseModule.class,
 		CryptoModule.class, LifecycleModule.class, ReliabilityModule.class,
-		MessagingModule.class, InvitationModule.class, ForumModule.class,
+		MessagingModule.class, InvitationModule.class, KeyAgreementModule.class,
+		ForumModule.class,
 		IdentityModule.class, EventModule.class, DataModule.class,
 		ContactModule.class, PropertiesModule.class, TransportModule.class,
 		SyncModule.class, SettingsModule.class, ClientsModule.class,
diff --git a/briar-core/src/org/briarproject/contact/ContactExchangeTaskImpl.java b/briar-core/src/org/briarproject/contact/ContactExchangeTaskImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..943d02de98598359dedbf1767411a6334b8ed504
--- /dev/null
+++ b/briar-core/src/org/briarproject/contact/ContactExchangeTaskImpl.java
@@ -0,0 +1,248 @@
+package org.briarproject.contact;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.TransportId;
+import org.briarproject.api.contact.ContactExchangeListener;
+import org.briarproject.api.contact.ContactExchangeTask;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.contact.ContactManager;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyParser;
+import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.crypto.Signature;
+import org.briarproject.api.data.BdfReader;
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.data.BdfWriter;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.db.ContactExistsException;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.plugins.ConnectionManager;
+import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
+import org.briarproject.api.system.Clock;
+import org.briarproject.api.transport.StreamReaderFactory;
+import org.briarproject.api.transport.StreamWriterFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.GeneralSecurityException;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
+import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
+
+public class ContactExchangeTaskImpl extends Thread
+		implements ContactExchangeTask {
+
+	private static final Logger LOG =
+			Logger.getLogger(ContactExchangeTaskImpl.class.getName());
+
+	private final AuthorFactory authorFactory;
+	private final BdfReaderFactory bdfReaderFactory;
+	private final BdfWriterFactory bdfWriterFactory;
+	private final Clock clock;
+	private final ConnectionManager connectionManager;
+	private final ContactManager contactManager;
+	private final CryptoComponent crypto;
+	private final StreamReaderFactory streamReaderFactory;
+	private final StreamWriterFactory streamWriterFactory;
+
+	private ContactExchangeListener listener;
+	private LocalAuthor localAuthor;
+	private DuplexTransportConnection conn;
+	private TransportId transportId;
+	private SecretKey masterSecret;
+	private boolean alice;
+
+	public ContactExchangeTaskImpl(AuthorFactory authorFactory,
+			BdfReaderFactory bdfReaderFactory,
+			BdfWriterFactory bdfWriterFactory, Clock clock,
+			ConnectionManager connectionManager, ContactManager contactManager,
+			CryptoComponent crypto, StreamReaderFactory streamReaderFactory,
+			StreamWriterFactory streamWriterFactory) {
+		this.authorFactory = authorFactory;
+		this.bdfReaderFactory = bdfReaderFactory;
+		this.bdfWriterFactory = bdfWriterFactory;
+		this.clock = clock;
+		this.connectionManager = connectionManager;
+		this.contactManager = contactManager;
+		this.crypto = crypto;
+		this.streamReaderFactory = streamReaderFactory;
+		this.streamWriterFactory = streamWriterFactory;
+	}
+
+	@Override
+	public void startExchange(ContactExchangeListener listener,
+			LocalAuthor localAuthor, SecretKey masterSecret,
+			DuplexTransportConnection conn, TransportId transportId,
+			boolean alice) {
+		this.listener = listener;
+		this.localAuthor = localAuthor;
+		this.conn = conn;
+		this.transportId = transportId;
+		this.masterSecret = masterSecret;
+		this.alice = alice;
+		start();
+	}
+
+	@Override
+	public void run() {
+		// Derive the header keys for the transport streams
+		SecretKey aliceHeaderKey = crypto.deriveHeaderKey(masterSecret, true);
+		SecretKey bobHeaderKey = crypto.deriveHeaderKey(masterSecret, false);
+		BdfReader r;
+		BdfWriter w;
+		try {
+			// Create the readers
+			InputStream streamReader =
+					streamReaderFactory.createInvitationStreamReader(
+							conn.getReader().getInputStream(),
+							alice ? bobHeaderKey : aliceHeaderKey);
+			r = bdfReaderFactory.createReader(streamReader);
+			// Create the writers
+			OutputStream streamWriter =
+					streamWriterFactory.createInvitationStreamWriter(
+							conn.getWriter().getOutputStream(),
+							alice ? aliceHeaderKey : bobHeaderKey);
+			w = bdfWriterFactory.createWriter(streamWriter);
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			listener.contactExchangeFailed();
+			tryToClose(conn, true);
+			return;
+		}
+
+		// Derive the nonces to be signed
+		byte[] aliceNonce = crypto.deriveSignatureNonce(masterSecret, true);
+		byte[] bobNonce = crypto.deriveSignatureNonce(masterSecret, false);
+
+		// Exchange pseudonyms, signed nonces, and timestamps
+		long localTimestamp = clock.currentTimeMillis();
+		Author remoteAuthor;
+		long remoteTimestamp;
+		try {
+			if (alice) {
+				sendPseudonym(w, aliceNonce);
+				sendTimestamp(w, localTimestamp);
+				remoteAuthor = receivePseudonym(r, bobNonce);
+				remoteTimestamp = receiveTimestamp(r);
+			} else {
+				remoteAuthor = receivePseudonym(r, aliceNonce);
+				remoteTimestamp = receiveTimestamp(r);
+				sendPseudonym(w, bobNonce);
+				sendTimestamp(w, localTimestamp);
+			}
+			// Close the outgoing stream and expect EOF on the incoming stream
+			w.close();
+			if (!r.eof()) LOG.warning("Unexpected data at end of connection");
+		} catch (GeneralSecurityException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			listener.contactExchangeFailed();
+			tryToClose(conn, true);
+			return;
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			listener.contactExchangeFailed();
+			tryToClose(conn, true);
+			return;
+		}
+
+		// The agreed timestamp is the minimum of the peers' timestamps
+		long timestamp = Math.min(localTimestamp, remoteTimestamp);
+
+		try {
+			// Add the contact
+			ContactId contactId = addContact(remoteAuthor, masterSecret,
+					timestamp, alice);
+			// Reuse the connection as a transport connection
+			connectionManager.manageOutgoingConnection(contactId, transportId,
+					conn);
+			// Pseudonym exchange succeeded
+			LOG.info("Pseudonym exchange succeeded");
+			listener.contactExchangeSucceeded(remoteAuthor);
+		} catch (ContactExistsException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			tryToClose(conn, true);
+			listener.duplicateContact(remoteAuthor);
+		} catch (DbException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			tryToClose(conn, true);
+			listener.contactExchangeFailed();
+		}
+	}
+
+	private void sendPseudonym(BdfWriter w, byte[] nonce)
+			throws GeneralSecurityException, IOException {
+		// Sign the nonce
+		Signature signature = crypto.getSignature();
+		KeyParser keyParser = crypto.getSignatureKeyParser();
+		byte[] privateKey = localAuthor.getPrivateKey();
+		signature.initSign(keyParser.parsePrivateKey(privateKey));
+		signature.update(nonce);
+		byte[] sig = signature.sign();
+		// Write the name, public key and signature
+		w.writeString(localAuthor.getName());
+		w.writeRaw(localAuthor.getPublicKey());
+		w.writeRaw(sig);
+		w.flush();
+		LOG.info("Sent pseudonym");
+	}
+
+	private Author receivePseudonym(BdfReader r, byte[] nonce)
+			throws GeneralSecurityException, IOException {
+		// Read the name, public key and signature
+		String name = r.readString(MAX_AUTHOR_NAME_LENGTH);
+		byte[] publicKey = r.readRaw(MAX_PUBLIC_KEY_LENGTH);
+		byte[] sig = r.readRaw(MAX_SIGNATURE_LENGTH);
+		LOG.info("Received pseudonym");
+		// Verify the signature
+		Signature signature = crypto.getSignature();
+		KeyParser keyParser = crypto.getSignatureKeyParser();
+		signature.initVerify(keyParser.parsePublicKey(publicKey));
+		signature.update(nonce);
+		if (!signature.verify(sig)) {
+			if (LOG.isLoggable(INFO))
+				LOG.info("Invalid signature");
+			throw new GeneralSecurityException();
+		}
+		return authorFactory.createAuthor(name, publicKey);
+	}
+
+	private void sendTimestamp(BdfWriter w, long timestamp)
+			throws IOException {
+		w.writeLong(timestamp);
+		w.flush();
+		LOG.info("Sent timestamp");
+	}
+
+	private long receiveTimestamp(BdfReader r) throws IOException {
+		long timestamp = r.readLong();
+		if (timestamp < 0) throw new FormatException();
+		LOG.info("Received timestamp");
+		return timestamp;
+	}
+
+	private ContactId addContact(Author remoteAuthor, SecretKey master,
+			long timestamp, boolean alice) throws DbException {
+		// Add the contact to the database
+		return contactManager.addContact(remoteAuthor, localAuthor.getId(),
+				master, timestamp, alice, true);
+	}
+
+	private void tryToClose(DuplexTransportConnection conn,
+			boolean exception) {
+		try {
+			LOG.info("Closing connection");
+			conn.getReader().dispose(exception, true);
+			conn.getWriter().dispose(exception);
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
+	}
+}
diff --git a/briar-core/src/org/briarproject/contact/ContactModule.java b/briar-core/src/org/briarproject/contact/ContactModule.java
index bd8e2d3afa05b27aedc9cf3117468995f9fbcf01..fb5c290ce2d3ad0915f57a8e44a7c6d71d4b0aec 100644
--- a/briar-core/src/org/briarproject/contact/ContactModule.java
+++ b/briar-core/src/org/briarproject/contact/ContactModule.java
@@ -1,8 +1,17 @@
 package org.briarproject.contact;
 
+import org.briarproject.api.contact.ContactExchangeTask;
 import org.briarproject.api.contact.ContactManager;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.plugins.ConnectionManager;
+import org.briarproject.api.system.Clock;
+import org.briarproject.api.transport.StreamReaderFactory;
+import org.briarproject.api.transport.StreamWriterFactory;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -25,4 +34,16 @@ public class ContactModule {
 		identityManager.registerRemoveIdentityHook(contactManager);
 		return contactManager;
 	}
+
+	@Provides
+	ContactExchangeTask provideContactExchangeTask(
+			AuthorFactory authorFactory, BdfReaderFactory bdfReaderFactory,
+			BdfWriterFactory bdfWriterFactory, Clock clock,
+			ConnectionManager connectionManager, ContactManager contactManager,
+			CryptoComponent crypto, StreamReaderFactory streamReaderFactory,
+			StreamWriterFactory streamWriterFactory) {
+		return new ContactExchangeTaskImpl(authorFactory, bdfReaderFactory,
+				bdfWriterFactory, clock, connectionManager, contactManager,
+				crypto, streamReaderFactory, streamWriterFactory);
+	}
 }
diff --git a/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java b/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java
index 2a779f3fe200e40f0d8194af97b9898f0dd75fc5..825c3720f5188500ec89aa7a83fb70096b14270a 100644
--- a/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java
+++ b/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java
@@ -63,24 +63,22 @@ class CryptoComponentImpl implements CryptoComponent {
 		return s.getBytes(Charset.forName("US-ASCII"));
 	}
 
-	// KDF label for bluetooth master key derivation
-	private static final byte[] BT_MASTER = ascii("MASTER");
 	// KDF labels for bluetooth confirmation code derivation
 	private static final byte[] BT_A_CONFIRM = ascii("ALICE_CONFIRMATION_CODE");
 	private static final byte[] BT_B_CONFIRM = ascii("BOB_CONFIRMATION_CODE");
-	// KDF labels for bluetooth invitation stream header key derivation
-	private static final byte[] BT_A_INVITE = ascii("ALICE_INVITATION_KEY");
-	private static final byte[] BT_B_INVITE = ascii("BOB_INVITATION_KEY");
-	// KDF labels for bluetooth signature nonce derivation
-	private static final byte[] BT_A_NONCE = ascii("ALICE_SIGNATURE_NONCE");
-	private static final byte[] BT_B_NONCE = ascii("BOB_SIGNATURE_NONCE");
+	// KDF labels for contact exchange stream header key derivation
+	private static final byte[] A_INVITE = ascii("ALICE_INVITATION_KEY");
+	private static final byte[] B_INVITE = ascii("BOB_INVITATION_KEY");
+	// KDF labels for contact exchange signature nonce derivation
+	private static final byte[] A_SIG_NONCE = ascii("ALICE_SIGNATURE_NONCE");
+	private static final byte[] B_SIG_NONCE = ascii("BOB_SIGNATURE_NONCE");
 	// Hash label for BQP public key commitment derivation
 	private static final byte[] COMMIT = ascii("COMMIT");
-	// Hash label for BQP shared secret derivation
+	// Hash label for shared secret derivation
 	private static final byte[] SHARED_SECRET = ascii("SHARED_SECRET");
 	// KDF label for BQP confirmation key derivation
 	private static final byte[] CONFIRMATION_KEY = ascii("CONFIRMATION_KEY");
-	// KDF label for BQP master key derivation
+	// KDF label for master key derivation
 	private static final byte[] MASTER_KEY = ascii("MASTER_KEY");
 	// KDF labels for tag key derivation
 	private static final byte[] A_TAG = ascii("ALICE_TAG_KEY");
@@ -210,12 +208,14 @@ class CryptoComponentImpl implements CryptoComponent {
 		return ByteUtils.readUint(b, CODE_BITS);
 	}
 
-	public SecretKey deriveBTInvitationKey(SecretKey master, boolean alice) {
-		return new SecretKey(macKdf(master, alice ? BT_A_INVITE : BT_B_INVITE));
+	public SecretKey deriveHeaderKey(SecretKey master,
+			boolean alice) {
+		return new SecretKey(macKdf(master, alice ? A_INVITE : B_INVITE));
 	}
 
-	public byte[] deriveBTSignatureNonce(SecretKey master, boolean alice) {
-		return macKdf(master, alice ? BT_A_NONCE : BT_B_NONCE);
+	public byte[] deriveSignatureNonce(SecretKey master,
+			boolean alice) {
+		return macKdf(master, alice ? A_SIG_NONCE : B_SIG_NONCE);
 	}
 
 	public byte[] deriveKeyCommitment(byte[] publicKey) {
@@ -438,29 +438,6 @@ class CryptoComponentImpl implements CryptoComponent {
 		}
 	}
 
-	// Key derivation function based on a hash function - see NIST SP 800-56A,
-	// section 5.8
-	private byte[] hashKdf(byte[]... inputs) {
-		Digest digest = new Blake2sDigest();
-		// The output of the hash function must be long enough to use as a key
-		int hashLength = digest.getDigestSize();
-		if (hashLength < SecretKey.LENGTH) throw new IllegalStateException();
-		// Calculate the hash over the concatenated length-prefixed inputs
-		byte[] length = new byte[INT_32_BYTES];
-		for (byte[] input : inputs) {
-			ByteUtils.writeUint32(input.length, length, 0);
-			digest.update(length, 0, length.length);
-			digest.update(input, 0, input.length);
-		}
-		byte[] hash = new byte[hashLength];
-		digest.doFinal(hash, 0);
-		// The output is the first SecretKey.LENGTH bytes of the hash
-		if (hash.length == SecretKey.LENGTH) return hash;
-		byte[] truncated = new byte[SecretKey.LENGTH];
-		System.arraycopy(hash, 0, truncated, 0, truncated.length);
-		return truncated;
-	}
-
 	// Key derivation function based on a pseudo-random function - see
 	// NIST SP 800-108, section 5.1
 	private byte[] macKdf(SecretKey key, byte[]... inputs) {
diff --git a/briar-core/src/org/briarproject/invitation/AliceConnector.java b/briar-core/src/org/briarproject/invitation/AliceConnector.java
index 210d173d9e6de69942a56c7a56b834134e1d4867..b2947ece4db90335101bc6cb4d1a82424cbaa8a6 100644
--- a/briar-core/src/org/briarproject/invitation/AliceConnector.java
+++ b/briar-core/src/org/briarproject/invitation/AliceConnector.java
@@ -125,8 +125,8 @@ class AliceConnector extends Connector {
 		if (LOG.isLoggable(INFO))
 			LOG.info(pluginName + " confirmation succeeded");
 		// Derive the header keys
-		SecretKey aliceHeaderKey = crypto.deriveBTInvitationKey(master, true);
-		SecretKey bobHeaderKey = crypto.deriveBTInvitationKey(master, false);
+		SecretKey aliceHeaderKey = crypto.deriveHeaderKey(master, true);
+		SecretKey bobHeaderKey = crypto.deriveHeaderKey(master, false);
 		// Create the readers
 		InputStream streamReader =
 				streamReaderFactory.createInvitationStreamReader(in,
@@ -138,8 +138,8 @@ class AliceConnector extends Connector {
 						aliceHeaderKey);
 		w = bdfWriterFactory.createWriter(streamWriter);
 		// Derive the invitation nonces
-		byte[] aliceNonce = crypto.deriveBTSignatureNonce(master, true);
-		byte[] bobNonce = crypto.deriveBTSignatureNonce(master, false);
+		byte[] aliceNonce = crypto.deriveSignatureNonce(master, true);
+		byte[] bobNonce = crypto.deriveSignatureNonce(master, false);
 		// Exchange pseudonyms, signed nonces, and timestamps
 		Author remoteAuthor;
 		long remoteTimestamp;
diff --git a/briar-core/src/org/briarproject/invitation/BobConnector.java b/briar-core/src/org/briarproject/invitation/BobConnector.java
index f2c6e2619673922d15a32db891563e5797f25d9d..1460b953ec1b3ec222ca14fc5e27ebfc93447966 100644
--- a/briar-core/src/org/briarproject/invitation/BobConnector.java
+++ b/briar-core/src/org/briarproject/invitation/BobConnector.java
@@ -125,8 +125,10 @@ class BobConnector extends Connector {
 		if (LOG.isLoggable(INFO))
 			LOG.info(pluginName + " confirmation succeeded");
 		// Derive the header keys
-		SecretKey aliceHeaderKey = crypto.deriveBTInvitationKey(master, true);
-		SecretKey bobHeaderKey = crypto.deriveBTInvitationKey(master, false);
+		SecretKey aliceHeaderKey = crypto.deriveHeaderKey(master,
+				true);
+		SecretKey bobHeaderKey = crypto.deriveHeaderKey(master,
+				false);
 		// Create the readers
 		InputStream streamReader =
 				streamReaderFactory.createInvitationStreamReader(in,
@@ -138,8 +140,10 @@ class BobConnector extends Connector {
 						bobHeaderKey);
 		w = bdfWriterFactory.createWriter(streamWriter);
 		// Derive the nonces
-		byte[] aliceNonce = crypto.deriveBTSignatureNonce(master, true);
-		byte[] bobNonce = crypto.deriveBTSignatureNonce(master, false);
+		byte[] aliceNonce = crypto.deriveSignatureNonce(master,
+				true);
+		byte[] bobNonce = crypto.deriveSignatureNonce(master,
+				false);
 		// Exchange pseudonyms, signed nonces and timestamps
 		Author remoteAuthor;
 		long remoteTimestamp;
diff --git a/briar-core/src/org/briarproject/keyagreement/AbortException.java b/briar-core/src/org/briarproject/keyagreement/AbortException.java
new file mode 100644
index 0000000000000000000000000000000000000000..670bbc3ee14f63d6bd1d543d58d06652576c4437
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/AbortException.java
@@ -0,0 +1,23 @@
+package org.briarproject.keyagreement;
+
+class AbortException extends Exception {
+	public boolean receivedAbort;
+
+	public AbortException() {
+		this(false);
+	}
+
+	public AbortException(boolean receivedAbort) {
+		super();
+		this.receivedAbort = receivedAbort;
+	}
+
+	public AbortException(Exception e) {
+		this(e, false);
+	}
+
+	public AbortException(Exception e, boolean receivedAbort) {
+		super(e);
+		this.receivedAbort = receivedAbort;
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/KeyAgreementConnector.java b/briar-core/src/org/briarproject/keyagreement/KeyAgreementConnector.java
new file mode 100644
index 0000000000000000000000000000000000000000..e87297af63402215582b79de7ad5bd98f63ec31e
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/KeyAgreementConnector.java
@@ -0,0 +1,236 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyPair;
+import org.briarproject.api.keyagreement.KeyAgreementConnection;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.TransportDescriptor;
+import org.briarproject.api.plugins.PluginManager;
+import org.briarproject.api.plugins.duplex.DuplexPlugin;
+import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
+import org.briarproject.api.system.Clock;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletionService;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorCompletionService;
+import java.util.concurrent.Future;
+import java.util.logging.Logger;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.CONNECTION_TIMEOUT;
+
+class KeyAgreementConnector {
+
+	interface Callbacks {
+		void connectionWaiting();
+	}
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementConnector.class.getName());
+
+	private final Callbacks callbacks;
+	private final Clock clock;
+	private final CryptoComponent crypto;
+	private final PluginManager pluginManager;
+	private final CompletionService<KeyAgreementConnection> connect;
+
+	private final List<KeyAgreementListener> listeners =
+			new ArrayList<KeyAgreementListener>();
+	private final List<Future<KeyAgreementConnection>> pending =
+			new ArrayList<Future<KeyAgreementConnection>>();
+
+	private volatile boolean connecting = false;
+	private volatile boolean alice = false;
+
+	public KeyAgreementConnector(Callbacks callbacks, Clock clock,
+			CryptoComponent crypto, PluginManager pluginManager,
+			Executor ioExecutor) {
+		this.callbacks = callbacks;
+		this.clock = clock;
+		this.crypto = crypto;
+		this.pluginManager = pluginManager;
+		connect = new ExecutorCompletionService<KeyAgreementConnection>(
+				ioExecutor);
+	}
+
+	public Payload listen(KeyPair localKeyPair) {
+		LOG.info("Starting BQP listeners");
+		// Derive commitment
+		byte[] commitment = crypto.deriveKeyCommitment(
+				localKeyPair.getPublic().getEncoded());
+		// Start all listeners and collect their descriptors
+		List<TransportDescriptor> descriptors =
+				new ArrayList<TransportDescriptor>();
+		for (DuplexPlugin plugin : pluginManager.getKeyAgreementPlugins()) {
+			KeyAgreementListener l = plugin.createKeyAgreementListener(
+					commitment);
+			if (l != null) {
+				TransportDescriptor d = l.getDescriptor();
+				descriptors.add(d);
+				pending.add(connect.submit(new ReadableTask(l.listen())));
+				listeners.add(l);
+			}
+		}
+		return new Payload(commitment, descriptors);
+	}
+
+	public void stopListening() {
+		LOG.info("Stopping BQP listeners");
+		for (KeyAgreementListener l : listeners) {
+			l.close();
+		}
+		listeners.clear();
+	}
+
+	public KeyAgreementTransport connect(Payload remotePayload,
+			boolean alice) {
+		// Let the listeners know if we are Alice
+		this.connecting = true;
+		this.alice = alice;
+		long end = clock.currentTimeMillis() + CONNECTION_TIMEOUT;
+
+		// Start connecting over supported transports
+		LOG.info("Starting outgoing BQP connections");
+		for (TransportDescriptor d : remotePayload.getTransportDescriptors()) {
+			DuplexPlugin plugin = (DuplexPlugin) pluginManager.getPlugin(
+					d.getIdentifier());
+			if (plugin != null)
+				pending.add(connect.submit(new ReadableTask(
+						new ConnectorTask(plugin, remotePayload.getCommitment(),
+								d, end))));
+		}
+
+		// Get chosen connection
+		KeyAgreementConnection chosen = null;
+		try {
+			long now = clock.currentTimeMillis();
+			Future<KeyAgreementConnection> f =
+					connect.poll(end - now, MILLISECONDS);
+			if (f == null)
+				return null; // No task completed within the timeout.
+			chosen = f.get();
+			return new KeyAgreementTransport(chosen);
+		} catch (InterruptedException e) {
+			LOG.info("Interrupted while waiting for connection");
+			Thread.currentThread().interrupt();
+			return null;
+		} catch (ExecutionException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			return null;
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			return null;
+		} finally {
+			stopListening();
+			// Close all other connections
+			closePending(chosen);
+		}
+	}
+
+	private void closePending(KeyAgreementConnection chosen) {
+		for (Future<KeyAgreementConnection> f : pending) {
+			try {
+				if (f.cancel(true))
+					LOG.info("Cancelled task");
+				else if (!f.isCancelled()) {
+					KeyAgreementConnection c = f.get();
+					if (c != null && c != chosen)
+						tryToClose(c.getConnection(), false);
+				}
+			} catch (InterruptedException e) {
+				LOG.info("Interrupted while closing sockets");
+				Thread.currentThread().interrupt();
+				return;
+			} catch (ExecutionException e) {
+				if (LOG.isLoggable(INFO)) LOG.info(e.toString());
+			}
+		}
+	}
+
+	private void tryToClose(DuplexTransportConnection conn, boolean exception) {
+		try {
+			if (LOG.isLoggable(INFO))
+				LOG.info("Closing connection, exception: " + exception);
+			conn.getReader().dispose(exception, true);
+			conn.getWriter().dispose(exception);
+		} catch (IOException e) {
+			if (LOG.isLoggable(INFO)) LOG.info(e.toString());
+		}
+	}
+
+	private class ConnectorTask implements Callable<KeyAgreementConnection> {
+
+		private final byte[] commitment;
+		private final TransportDescriptor descriptor;
+		private final long end;
+		private final DuplexPlugin plugin;
+
+		private ConnectorTask(DuplexPlugin plugin, byte[] commitment,
+				TransportDescriptor descriptor, long end) {
+			this.plugin = plugin;
+			this.commitment = commitment;
+			this.descriptor = descriptor;
+			this.end = end;
+		}
+
+		@Override
+		public KeyAgreementConnection call() throws Exception {
+			// Repeat attempts until we connect or get interrupted
+			while (true) {
+				long now = clock.currentTimeMillis();
+				DuplexTransportConnection conn =
+						plugin.createKeyAgreementConnection(commitment,
+								descriptor, end - now);
+				if (conn != null) {
+					if (LOG.isLoggable(INFO))
+						LOG.info(plugin.getId().getString() +
+								": Outgoing connection");
+					return new KeyAgreementConnection(conn, plugin.getId());
+				}
+				// Wait 2s before retry (to circumvent transient failures)
+				Thread.sleep(2000);
+			}
+		}
+	}
+
+	private class ReadableTask
+			implements Callable<KeyAgreementConnection> {
+
+		private final Callable<KeyAgreementConnection> connectionTask;
+
+		private ReadableTask(Callable<KeyAgreementConnection> connectionTask) {
+			this.connectionTask = connectionTask;
+		}
+
+		@Override
+		public KeyAgreementConnection call()
+				throws Exception {
+			KeyAgreementConnection c = connectionTask.call();
+			InputStream in = c.getConnection().getReader().getInputStream();
+			boolean waitingSent = false;
+			while (!alice && in.available() == 0) {
+				if (!waitingSent && connecting && !alice) {
+					// Bob waits here until Alice obtains his payload.
+					callbacks.connectionWaiting();
+					waitingSent = true;
+				}
+				if (LOG.isLoggable(INFO))
+					LOG.info(c.getTransportId().toString() +
+							": Waiting for connection");
+				Thread.sleep(1000);
+			}
+			if (!alice && LOG.isLoggable(INFO))
+				LOG.info(c.getTransportId().toString() + ": Data available");
+			return c;
+		}
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/KeyAgreementModule.java b/briar-core/src/org/briarproject/keyagreement/KeyAgreementModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..9f6a529028a668fc4cbde1231a3a61201730f847
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/KeyAgreementModule.java
@@ -0,0 +1,43 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.keyagreement.KeyAgreementTaskFactory;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.keyagreement.PayloadParser;
+import org.briarproject.api.lifecycle.IoExecutor;
+import org.briarproject.api.plugins.PluginManager;
+import org.briarproject.api.system.Clock;
+
+import java.util.concurrent.Executor;
+
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+
+@Module
+public class KeyAgreementModule {
+
+	@Provides
+	@Singleton
+	KeyAgreementTaskFactory provideKeyAgreementTaskFactory(Clock clock,
+			CryptoComponent crypto, EventBus eventBus,
+			@IoExecutor Executor ioExecutor, PayloadEncoder payloadEncoder,
+			PluginManager pluginManager) {
+		return new KeyAgreementTaskFactoryImpl(clock, crypto, eventBus,
+				ioExecutor, payloadEncoder, pluginManager);
+	}
+
+	@Provides
+	PayloadEncoder providePayloadEncoder(BdfWriterFactory bdfWriterFactory) {
+		return new PayloadEncoderImpl(bdfWriterFactory);
+	}
+
+	@Provides
+	PayloadParser providePayloadParser(BdfReaderFactory bdfReaderFactory) {
+		return new PayloadParserImpl(bdfReaderFactory);
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/KeyAgreementProtocol.java b/briar-core/src/org/briarproject/keyagreement/KeyAgreementProtocol.java
new file mode 100644
index 0000000000000000000000000000000000000000..c832edd092e10c3227eeed2e538c7ed25b901f88
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/KeyAgreementProtocol.java
@@ -0,0 +1,157 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyPair;
+import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+/**
+ * Implementation of the BQP protocol.
+ * <p/>
+ * Alice:
+ * <ul>
+ *     <li>Send A_KEY</li>
+ *     <li>Receive B_KEY
+ *     <ul>
+ *         <li>Check B_KEY matches B_COMMIT</li>
+ *     </ul></li>
+ *     <li>Calculate s</li>
+ *     <li>Send A_CONFIRM</li>
+ *     <li>Receive B_CONFIRM
+ *     <ul>
+ *         <li>Check B_CONFIRM matches expected</li>
+ *     </ul></li>
+ *     <li>Derive master</li>
+ * </ul><p/>
+ * Bob:
+ * <ul>
+ *     <li>Receive A_KEY
+ *     <ul>
+ *         <li>Check A_KEY matches A_COMMIT</li>
+ *     </ul></li>
+ *     <li>Send B_KEY</li>
+ *     <li>Calculate s</li>
+ *     <li>Receive A_CONFIRM
+ *     <ul>
+ *         <li>Check A_CONFIRM matches expected</li>
+ *     </ul></li>
+ *     <li>Send B_CONFIRM</li>
+ *     <li>Derive master</li>
+ * </ul>
+ */
+class KeyAgreementProtocol {
+
+	interface Callbacks {
+		void connectionWaiting();
+		void initialPacketReceived();
+	}
+
+	private Callbacks callbacks;
+	private CryptoComponent crypto;
+	private PayloadEncoder payloadEncoder;
+	private KeyAgreementTransport transport;
+	private Payload theirPayload, ourPayload;
+	private KeyPair ourKeyPair;
+	private boolean alice;
+
+	public KeyAgreementProtocol(Callbacks callbacks, CryptoComponent crypto,
+			PayloadEncoder payloadEncoder, KeyAgreementTransport transport,
+			Payload theirPayload, Payload ourPayload, KeyPair ourKeyPair,
+			boolean alice) {
+		this.callbacks = callbacks;
+		this.crypto = crypto;
+		this.payloadEncoder = payloadEncoder;
+		this.transport = transport;
+		this.theirPayload = theirPayload;
+		this.ourPayload = ourPayload;
+		this.ourKeyPair = ourKeyPair;
+		this.alice = alice;
+	}
+
+	/**
+	 * Perform the BQP protocol.
+	 *
+	 * @return the negotiated master secret.
+	 * @throws AbortException when the protocol may have been tampered with.
+	 * @throws IOException for all other other connection errors.
+	 */
+	public SecretKey perform() throws AbortException, IOException {
+		try {
+			byte[] theirPublicKey;
+			if (alice) {
+				sendKey();
+				// Alice waits here until Bob obtains her payload.
+				callbacks.connectionWaiting();
+				theirPublicKey = receiveKey();
+			} else {
+				theirPublicKey = receiveKey();
+				sendKey();
+			}
+			SecretKey s = deriveSharedSecret(theirPublicKey);
+			if (alice) {
+				sendConfirm(s, theirPublicKey);
+				receiveConfirm(s, theirPublicKey);
+			} else {
+				receiveConfirm(s, theirPublicKey);
+				sendConfirm(s, theirPublicKey);
+			}
+			return crypto.deriveMasterSecret(s);
+		} catch (AbortException e) {
+			sendAbort(e.getCause() != null);
+			throw e;
+		}
+	}
+
+	private void sendKey() throws IOException {
+		transport.sendKey(ourKeyPair.getPublic().getEncoded());
+	}
+
+	private byte[] receiveKey() throws AbortException {
+		byte[] publicKey = transport.receiveKey();
+		callbacks.initialPacketReceived();
+		byte[] expected = crypto.deriveKeyCommitment(publicKey);
+		if (!Arrays.equals(expected, theirPayload.getCommitment()))
+			throw new AbortException();
+		return publicKey;
+	}
+
+	private SecretKey deriveSharedSecret(byte[] theirPublicKey)
+			throws AbortException {
+		try {
+			return crypto.deriveSharedSecret(theirPublicKey, ourKeyPair, alice);
+		} catch (GeneralSecurityException e) {
+			throw new AbortException(e);
+		}
+	}
+
+	private void sendConfirm(SecretKey s, byte[] theirPublicKey)
+			throws IOException {
+		byte[] confirm = crypto.deriveConfirmationRecord(s,
+				payloadEncoder.encode(theirPayload),
+				payloadEncoder.encode(ourPayload),
+				theirPublicKey, ourKeyPair,
+				alice, alice);
+		transport.sendConfirm(confirm);
+	}
+
+	private void receiveConfirm(SecretKey s, byte[] theirPublicKey)
+			throws AbortException {
+		byte[] confirm = transport.receiveConfirm();
+		byte[] expected = crypto.deriveConfirmationRecord(s,
+				payloadEncoder.encode(theirPayload),
+				payloadEncoder.encode(ourPayload),
+				theirPublicKey, ourKeyPair,
+				alice, !alice);
+		if (!Arrays.equals(expected, confirm))
+			throw new AbortException();
+	}
+
+	private void sendAbort(boolean exception) {
+		transport.sendAbort(exception);
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/KeyAgreementTaskFactoryImpl.java b/briar-core/src/org/briarproject/keyagreement/KeyAgreementTaskFactoryImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..aafde49b3b1acb32aa01c995b9402a96cf615e41
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/KeyAgreementTaskFactoryImpl.java
@@ -0,0 +1,46 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.EventListener;
+import org.briarproject.api.event.KeyAgreementAbortedEvent;
+import org.briarproject.api.event.KeyAgreementFailedEvent;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.keyagreement.KeyAgreementTask;
+import org.briarproject.api.keyagreement.KeyAgreementTaskFactory;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.lifecycle.IoExecutor;
+import org.briarproject.api.plugins.PluginManager;
+import org.briarproject.api.system.Clock;
+
+import java.util.concurrent.Executor;
+
+import javax.inject.Inject;
+
+class KeyAgreementTaskFactoryImpl implements KeyAgreementTaskFactory {
+
+	private final Clock clock;
+	private final CryptoComponent crypto;
+	private final EventBus eventBus;
+	private final Executor ioExecutor;
+	private final PayloadEncoder payloadEncoder;
+	private final PluginManager pluginManager;
+
+	@Inject
+	KeyAgreementTaskFactoryImpl(Clock clock, CryptoComponent crypto,
+			EventBus eventBus, @IoExecutor Executor ioExecutor,
+			PayloadEncoder payloadEncoder, PluginManager pluginManager) {
+		this.clock = clock;
+		this.crypto = crypto;
+		this.eventBus = eventBus;
+		this.ioExecutor = ioExecutor;
+		this.payloadEncoder = payloadEncoder;
+		this.pluginManager = pluginManager;
+	}
+
+	public KeyAgreementTask getTask() {
+		return new KeyAgreementTaskImpl(clock, crypto, eventBus, payloadEncoder,
+				pluginManager, ioExecutor);
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/KeyAgreementTaskImpl.java b/briar-core/src/org/briarproject/keyagreement/KeyAgreementTaskImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..12f7678740421fbae7f1251c7f7a107cb6b967f8
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/KeyAgreementTaskImpl.java
@@ -0,0 +1,135 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyPair;
+import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.KeyAgreementAbortedEvent;
+import org.briarproject.api.event.KeyAgreementFailedEvent;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.event.KeyAgreementListeningEvent;
+import org.briarproject.api.event.KeyAgreementStartedEvent;
+import org.briarproject.api.event.KeyAgreementWaitingEvent;
+import org.briarproject.api.keyagreement.KeyAgreementResult;
+import org.briarproject.api.keyagreement.KeyAgreementTask;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.plugins.PluginManager;
+import org.briarproject.api.system.Clock;
+
+import java.io.IOException;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+
+class KeyAgreementTaskImpl extends Thread implements
+		KeyAgreementTask, KeyAgreementConnector.Callbacks,
+		KeyAgreementProtocol.Callbacks {
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementTaskImpl.class.getName());
+
+	private final CryptoComponent crypto;
+	private final EventBus eventBus;
+	private final PayloadEncoder payloadEncoder;
+	private final KeyPair localKeyPair;
+	private final KeyAgreementConnector connector;
+
+	private Payload localPayload;
+	private Payload remotePayload;
+
+	public KeyAgreementTaskImpl(Clock clock, CryptoComponent crypto,
+			EventBus eventBus, PayloadEncoder payloadEncoder,
+			PluginManager pluginManager, Executor ioExecutor) {
+		this.crypto = crypto;
+		this.eventBus = eventBus;
+		this.payloadEncoder = payloadEncoder;
+		localKeyPair = crypto.generateAgreementKeyPair();
+		connector = new KeyAgreementConnector(this, clock, crypto,
+				pluginManager, ioExecutor);
+	}
+
+	@Override
+	public synchronized void listen() {
+		if (localPayload == null) {
+			localPayload = connector.listen(localKeyPair);
+			eventBus.broadcast(new KeyAgreementListeningEvent(localPayload));
+		}
+	}
+
+	@Override
+	public synchronized void stopListening() {
+		if (localPayload != null) {
+			if (remotePayload == null)
+				connector.stopListening();
+			else
+				interrupt();
+		}
+	}
+
+	@Override
+	public synchronized void connectAndRunProtocol(Payload remotePayload) {
+		if (this.localPayload == null)
+			throw new IllegalStateException(
+					"Must listen before connecting");
+		if (this.remotePayload != null)
+			throw new IllegalStateException(
+					"Already provided remote payload for this task");
+		this.remotePayload = remotePayload;
+		start();
+	}
+
+	@Override
+	public void run() {
+		boolean alice = localPayload.compareTo(remotePayload) < 0;
+
+		// Open connection to remote device
+		KeyAgreementTransport transport =
+				connector.connect(remotePayload, alice);
+		if (transport == null) {
+			// Notify caller that the connection failed
+			eventBus.broadcast(new KeyAgreementFailedEvent());
+			return;
+		}
+
+		// Run BQP protocol over the connection
+		LOG.info("Starting BQP protocol");
+		KeyAgreementProtocol protocol = new KeyAgreementProtocol(this, crypto,
+				payloadEncoder, transport, remotePayload, localPayload,
+				localKeyPair, alice);
+		try {
+			SecretKey master = protocol.perform();
+			KeyAgreementResult result =
+					new KeyAgreementResult(master, transport.getConnection(),
+							transport.getTransportId(), alice);
+			LOG.info("Finished BQP protocol");
+			// Broadcast result to caller
+			eventBus.broadcast(new KeyAgreementFinishedEvent(result));
+		} catch (AbortException e) {
+			if (LOG.isLoggable(WARNING))
+				LOG.log(WARNING, e.toString(), e);
+			// Notify caller that the protocol was aborted
+			eventBus.broadcast(new KeyAgreementAbortedEvent(e.receivedAbort));
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING))
+				LOG.log(WARNING, e.toString(), e);
+			// Notify caller that the connection failed
+			eventBus.broadcast(new KeyAgreementFailedEvent());
+		}
+	}
+
+	@Override
+	public void connectionWaiting() {
+		eventBus.broadcast(new KeyAgreementWaitingEvent());
+	}
+
+	@Override
+	public void initialPacketReceived() {
+		// We send this here instead of when we create the protocol, so that
+		// if device A makes a connection after getting device B's payload and
+		// starts its protocol, device A's UI doesn't change to prevent device B
+		// from getting device A's payload.
+		eventBus.broadcast(new KeyAgreementStartedEvent());
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/KeyAgreementTransport.java b/briar-core/src/org/briarproject/keyagreement/KeyAgreementTransport.java
new file mode 100644
index 0000000000000000000000000000000000000000..25a18962bad176a7d58535d2fd5009d6977b1a51
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/KeyAgreementTransport.java
@@ -0,0 +1,131 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.TransportId;
+import org.briarproject.api.keyagreement.KeyAgreementConnection;
+import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
+import org.briarproject.util.ByteUtils;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.PROTOCOL_VERSION;
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.RECORD_HEADER_LENGTH;
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.RECORD_HEADER_PAYLOAD_LENGTH_OFFSET;
+import static org.briarproject.api.keyagreement.RecordTypes.ABORT;
+import static org.briarproject.api.keyagreement.RecordTypes.CONFIRM;
+import static org.briarproject.api.keyagreement.RecordTypes.KEY;
+
+/**
+ * Handles the sending and receiving of BQP records.
+ */
+class KeyAgreementTransport {
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementTransport.class.getName());
+
+	private final KeyAgreementConnection kac;
+	private final InputStream in;
+	private final OutputStream out;
+
+	public KeyAgreementTransport(KeyAgreementConnection kac)
+			throws IOException {
+		this.kac = kac;
+		in = kac.getConnection().getReader().getInputStream();
+		out = kac.getConnection().getWriter().getOutputStream();
+	}
+
+	public DuplexTransportConnection getConnection() {
+		return kac.getConnection();
+	}
+
+	public TransportId getTransportId() {
+		return kac.getTransportId();
+	}
+
+	public void sendKey(byte[] key) throws IOException {
+		writeRecord(KEY, key);
+	}
+
+	public byte[] receiveKey() throws AbortException {
+		return readRecord(KEY);
+	}
+
+	public void sendConfirm(byte[] confirm) throws IOException {
+		writeRecord(CONFIRM, confirm);
+	}
+
+	public byte[] receiveConfirm() throws AbortException {
+		return readRecord(CONFIRM);
+	}
+
+	public void sendAbort(boolean exception) {
+		try {
+			writeRecord(ABORT, new byte[0]);
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			exception = true;
+		}
+		tryToClose(exception);
+	}
+
+	public void tryToClose(boolean exception) {
+		try {
+			LOG.info("Closing connection");
+			kac.getConnection().getReader().dispose(exception, true);
+			kac.getConnection().getWriter().dispose(exception);
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
+	}
+
+	private void writeRecord(byte type, byte[] payload) throws IOException {
+		byte[] recordHeader = new byte[RECORD_HEADER_LENGTH];
+		recordHeader[0] = PROTOCOL_VERSION;
+		recordHeader[1] = type;
+		ByteUtils.writeUint16(payload.length, recordHeader,
+				RECORD_HEADER_PAYLOAD_LENGTH_OFFSET);
+		out.write(recordHeader);
+		out.write(payload);
+		out.flush();
+	}
+
+	private byte[] readRecord(byte type) throws AbortException {
+		byte[] header = readHeader();
+		if (header[0] != PROTOCOL_VERSION)
+			throw new AbortException(); // TODO handle?
+		if (header[1] != type) {
+			// Unexpected packet
+			throw new AbortException(header[1] == ABORT);
+		}
+		int len = ByteUtils.readUint16(header,
+				RECORD_HEADER_PAYLOAD_LENGTH_OFFSET);
+		try {
+			return readData(len);
+		} catch (IOException e) {
+			throw new AbortException(e);
+		}
+	}
+
+	private byte[] readHeader() throws AbortException {
+		try {
+			return readData(RECORD_HEADER_LENGTH);
+		} catch (IOException e) {
+			throw new AbortException(e);
+		}
+	}
+
+	private byte[] readData(int len) throws IOException {
+		byte[] data = new byte[len];
+		int offset = 0;
+		while (offset < data.length) {
+			int read = in.read(data, offset, data.length - offset);
+			if (read == -1) throw new EOFException();
+			offset += read;
+		}
+		return data;
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/PayloadEncoderImpl.java b/briar-core/src/org/briarproject/keyagreement/PayloadEncoderImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..8a26f9405bd17128dc7cfde4ca89bb90859f13d0
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/PayloadEncoderImpl.java
@@ -0,0 +1,48 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.data.BdfWriter;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.keyagreement.TransportDescriptor;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import javax.inject.Inject;
+
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.PROTOCOL_VERSION;
+
+class PayloadEncoderImpl implements PayloadEncoder {
+
+	private final BdfWriterFactory bdfWriterFactory;
+
+	@Inject
+	public PayloadEncoderImpl(BdfWriterFactory bdfWriterFactory) {
+		this.bdfWriterFactory = bdfWriterFactory;
+	}
+
+	@Override
+	public byte[] encode(Payload p) {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		BdfWriter w = bdfWriterFactory.createWriter(out);
+		try {
+			w.writeListStart(); // Payload start
+			w.writeLong(PROTOCOL_VERSION);
+			w.writeRaw(p.getCommitment());
+			w.writeListStart(); // Descriptors start
+			for (TransportDescriptor d : p.getTransportDescriptors()) {
+				w.writeListStart();
+				w.writeString(d.getIdentifier().getString());
+				w.writeDictionary(d.getProperties());
+				w.writeListEnd();
+			}
+			w.writeListEnd(); // Descriptors end
+			w.writeListEnd(); // Payload end
+		} catch (IOException e) {
+			// Shouldn't happen with ByteArrayOutputStream
+			throw new RuntimeException(e);
+		}
+		return out.toByteArray();
+	}
+}
diff --git a/briar-core/src/org/briarproject/keyagreement/PayloadParserImpl.java b/briar-core/src/org/briarproject/keyagreement/PayloadParserImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..d13f9ff73228af96b26a814a6d55e19a3e4d9a2b
--- /dev/null
+++ b/briar-core/src/org/briarproject/keyagreement/PayloadParserImpl.java
@@ -0,0 +1,68 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.TransportId;
+import org.briarproject.api.data.BdfReader;
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadParser;
+import org.briarproject.api.keyagreement.TransportDescriptor;
+import org.briarproject.api.properties.TransportProperties;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.inject.Inject;
+
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.COMMIT_LENGTH;
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.PROTOCOL_VERSION;
+import static org.briarproject.api.properties.TransportPropertyConstants.MAX_PROPERTY_LENGTH;
+
+class PayloadParserImpl implements PayloadParser {
+
+	private final BdfReaderFactory bdfReaderFactory;
+
+	@Inject
+	public PayloadParserImpl(BdfReaderFactory bdfReaderFactory) {
+		this.bdfReaderFactory = bdfReaderFactory;
+	}
+
+	@Override
+	public Payload parse(byte[] raw) throws IOException {
+		ByteArrayInputStream in = new ByteArrayInputStream(raw);
+		BdfReader r = bdfReaderFactory.createReader(in);
+		r.readListStart(); // Payload start
+		int proto = (int) r.readLong();
+		if (proto != PROTOCOL_VERSION)
+			throw new FormatException();
+		byte[] commitment = r.readRaw(COMMIT_LENGTH);
+		if (commitment.length != COMMIT_LENGTH)
+			throw new FormatException();
+		List<TransportDescriptor> descriptors = new ArrayList<TransportDescriptor>();
+		r.readListStart(); // Descriptors start
+		while (r.hasList()) {
+			r.readListStart();
+			while (!r.hasListEnd()) {
+				TransportId id =
+						new TransportId(r.readString(MAX_PROPERTY_LENGTH));
+				TransportProperties p = new TransportProperties();
+				r.readDictionaryStart();
+				while (!r.hasDictionaryEnd()) {
+					String key = r.readString(MAX_PROPERTY_LENGTH);
+					String value = r.readString(MAX_PROPERTY_LENGTH);
+					p.put(key, value);
+				}
+				r.readDictionaryEnd();
+				descriptors.add(new TransportDescriptor(id, p));
+			}
+			r.readListEnd();
+		}
+		r.readListEnd(); // Descriptors end
+		r.readListEnd(); // Payload end
+		if (!r.eof())
+			throw new FormatException();
+		return new Payload(commitment, descriptors);
+	}
+}
diff --git a/briar-core/src/org/briarproject/plugins/PluginManagerImpl.java b/briar-core/src/org/briarproject/plugins/PluginManagerImpl.java
index 367b1b66c5be99be31d6e20c930f5702bab09893..ef6e65e19d94ceb28a69c84f746e8d943a51718e 100644
--- a/briar-core/src/org/briarproject/plugins/PluginManagerImpl.java
+++ b/briar-core/src/org/briarproject/plugins/PluginManagerImpl.java
@@ -149,6 +149,13 @@ class PluginManagerImpl implements PluginManager, Service {
 		return Collections.unmodifiableList(supported);
 	}
 
+	public Collection<DuplexPlugin> getKeyAgreementPlugins() {
+		List<DuplexPlugin> supported = new ArrayList<DuplexPlugin>();
+		for (DuplexPlugin d : duplexPlugins)
+			if (d.supportsKeyAgreement()) supported.add(d);
+		return Collections.unmodifiableList(supported);
+	}
+
 	private class SimplexPluginStarter implements Runnable {
 
 		private final SimplexPluginFactory factory;
diff --git a/briar-core/src/org/briarproject/plugins/tcp/TcpPlugin.java b/briar-core/src/org/briarproject/plugins/tcp/TcpPlugin.java
index 54376310a0c33bd0b4e4f665f503d1c1ca97b76e..d6effe2a722930d94a5a6dac0022e02005ecb30c 100644
--- a/briar-core/src/org/briarproject/plugins/tcp/TcpPlugin.java
+++ b/briar-core/src/org/briarproject/plugins/tcp/TcpPlugin.java
@@ -2,6 +2,8 @@ package org.briarproject.plugins.tcp;
 
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.PseudoRandom;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.TransportDescriptor;
 import org.briarproject.api.plugins.Backoff;
 import org.briarproject.api.plugins.duplex.DuplexPlugin;
 import org.briarproject.api.plugins.duplex.DuplexPluginCallback;
@@ -250,6 +252,20 @@ abstract class TcpPlugin implements DuplexPlugin {
 		throw new UnsupportedOperationException();
 	}
 
+	public boolean supportsKeyAgreement() {
+		return false;
+	}
+
+	public KeyAgreementListener createKeyAgreementListener(
+			byte[] commitment) {
+		throw new UnsupportedOperationException();
+	}
+
+	public DuplexTransportConnection createKeyAgreementConnection(
+			byte[] commitment, TransportDescriptor d, long timeout) {
+		throw new UnsupportedOperationException();
+	}
+
 	protected Collection<InetAddress> getLocalIpAddresses() {
 		List<NetworkInterface> ifaces;
 		try {
diff --git a/briar-desktop/src/org/briarproject/plugins/bluetooth/BluetoothPlugin.java b/briar-desktop/src/org/briarproject/plugins/bluetooth/BluetoothPlugin.java
index 79e289c39ca3dd1b538f65f6e037a39a97e21f0f..875112c5e88abe0baed8035d2a9d0da319000ec8 100644
--- a/briar-desktop/src/org/briarproject/plugins/bluetooth/BluetoothPlugin.java
+++ b/briar-desktop/src/org/briarproject/plugins/bluetooth/BluetoothPlugin.java
@@ -3,6 +3,9 @@ package org.briarproject.plugins.bluetooth;
 import org.briarproject.api.TransportId;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.PseudoRandom;
+import org.briarproject.api.keyagreement.KeyAgreementConnection;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.TransportDescriptor;
 import org.briarproject.api.plugins.Backoff;
 import org.briarproject.api.plugins.duplex.DuplexPlugin;
 import org.briarproject.api.plugins.duplex.DuplexPluginCallback;
@@ -50,6 +53,9 @@ class BluetoothPlugin implements DuplexPlugin {
 			Logger.getLogger(BluetoothPlugin.class.getName());
 	private static final int UUID_BYTES = 16;
 
+	private static final String PROP_ADDRESS = "address";
+	private static final String PROP_UUID = "uuid";
+
 	private final Executor ioExecutor;
 	private final SecureRandom secureRandom;
 	private final Backoff backoff;
@@ -106,7 +112,7 @@ class BluetoothPlugin implements DuplexPlugin {
 				if (!running) return;
 				// Advertise the Bluetooth address to contacts
 				TransportProperties p = new TransportProperties();
-				p.put("address", localDevice.getBluetoothAddress());
+				p.put(PROP_ADDRESS, localDevice.getBluetoothAddress());
 				callback.mergeLocalProperties(p);
 				// Bind a server socket to accept connections from contacts
 				String url = makeUrl("localhost", getUuid());
@@ -135,13 +141,13 @@ class BluetoothPlugin implements DuplexPlugin {
 	}
 
 	private String getUuid() {
-		String uuid = callback.getLocalProperties().get("uuid");
+		String uuid = callback.getLocalProperties().get(PROP_UUID);
 		if (uuid == null) {
 			byte[] random = new byte[UUID_BYTES];
 			secureRandom.nextBytes(random);
 			uuid = UUID.nameUUIDFromBytes(random).toString();
 			TransportProperties p = new TransportProperties();
-			p.put("uuid", uuid);
+			p.put(PROP_UUID, uuid);
 			callback.mergeLocalProperties(p);
 		}
 		return uuid;
@@ -203,9 +209,9 @@ class BluetoothPlugin implements DuplexPlugin {
 		for (Entry<ContactId, TransportProperties> e : remote.entrySet()) {
 			final ContactId c = e.getKey();
 			if (connected.contains(c)) continue;
-			final String address = e.getValue().get("address");
+			final String address = e.getValue().get(PROP_ADDRESS);
 			if (StringUtils.isNullOrEmpty(address)) continue;
-			final String uuid = e.getValue().get("uuid");
+			final String uuid = e.getValue().get(PROP_UUID);
 			if (StringUtils.isNullOrEmpty(uuid)) continue;
 			ioExecutor.execute(new Runnable() {
 				public void run() {
@@ -236,9 +242,9 @@ class BluetoothPlugin implements DuplexPlugin {
 		if (!running) return null;
 		TransportProperties p = callback.getRemoteProperties().get(c);
 		if (p == null) return null;
-		String address = p.get("address");
+		String address = p.get(PROP_ADDRESS);
 		if (StringUtils.isNullOrEmpty(address)) return null;
-		String uuid = p.get("uuid");
+		String uuid = p.get(PROP_UUID);
 		if (StringUtils.isNullOrEmpty(uuid)) return null;
 		String url = makeUrl(address, uuid);
 		StreamConnection s = connect(url);
@@ -335,6 +341,54 @@ class BluetoothPlugin implements DuplexPlugin {
 		});
 	}
 
+	public boolean supportsKeyAgreement() {
+		return true;
+	}
+
+	public KeyAgreementListener createKeyAgreementListener(
+			byte[] localCommitment) {
+		// No truncation necessary because COMMIT_LENGTH = 16
+		String uuid = UUID.nameUUIDFromBytes(localCommitment).toString();
+		if (LOG.isLoggable(INFO)) LOG.info("Key agreement UUID " + uuid);
+		String url = makeUrl("localhost", uuid);
+		// Make the device discoverable if possible
+		makeDeviceDiscoverable();
+		// Bind a server socket for receiving invitation connections
+		final StreamConnectionNotifier ss;
+		try {
+			ss = (StreamConnectionNotifier) Connector.open(url);
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			return null;
+		}
+		if (!running) {
+			tryToClose(ss);
+			return null;
+		}
+		TransportProperties p = new TransportProperties();
+		p.put(PROP_ADDRESS, localDevice.getBluetoothAddress());
+		TransportDescriptor d = new TransportDescriptor(ID, p);
+		return new BluetoothKeyAgreementListener(d, ss);
+	}
+
+	public DuplexTransportConnection createKeyAgreementConnection(
+			byte[] remoteCommitment, TransportDescriptor d, long timeout) {
+		if (!isRunning()) return null;
+		if (!ID.equals(d.getIdentifier())) return null;
+		TransportProperties p = d.getProperties();
+		if (p == null) return null;
+		String address = p.get(PROP_ADDRESS);
+		if (StringUtils.isNullOrEmpty(address)) return null;
+		// No truncation necessary because COMMIT_LENGTH = 16
+		String uuid = UUID.nameUUIDFromBytes(remoteCommitment).toString();
+		if (LOG.isLoggable(INFO))
+			LOG.info("Connecting to key agreement UUID " + uuid);
+		String url = makeUrl(address, uuid);
+		StreamConnection s = connect(url);
+		if (s == null) return null;
+		return new BluetoothTransportConnection(this, s);
+	}
+
 	private void makeDeviceDiscoverable() {
 		// Try to make the device discoverable (requires root on Linux)
 		try {
@@ -414,4 +468,39 @@ class BluetoothPlugin implements DuplexPlugin {
 			return s;
 		}
 	}
+
+	private class BluetoothKeyAgreementListener extends KeyAgreementListener {
+
+		private final StreamConnectionNotifier ss;
+
+		public BluetoothKeyAgreementListener(TransportDescriptor descriptor,
+				StreamConnectionNotifier ss) {
+			super(descriptor);
+			this.ss = ss;
+		}
+
+		@Override
+		public Callable<KeyAgreementConnection> listen() {
+			return new Callable<KeyAgreementConnection>() {
+				@Override
+				public KeyAgreementConnection call() throws Exception {
+					StreamConnection s = ss.acceptAndOpen();
+					if (LOG.isLoggable(INFO))
+						LOG.info(ID.getString() + ": Incoming connection");
+					return new KeyAgreementConnection(
+							new BluetoothTransportConnection(
+									BluetoothPlugin.this, s), ID);
+				}
+			};
+		}
+
+		@Override
+		public void close() {
+			try {
+				ss.close();
+			} catch (IOException e) {
+				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			}
+		}
+	}
 }
diff --git a/briar-desktop/src/org/briarproject/plugins/modem/ModemPlugin.java b/briar-desktop/src/org/briarproject/plugins/modem/ModemPlugin.java
index ffa06c2a477689045735b9c1aa5cd28ebd04ae07..bbf71da9c0cafe5e92c2ce2f1d056ad29b6a20f3 100644
--- a/briar-desktop/src/org/briarproject/plugins/modem/ModemPlugin.java
+++ b/briar-desktop/src/org/briarproject/plugins/modem/ModemPlugin.java
@@ -3,6 +3,8 @@ package org.briarproject.plugins.modem;
 import org.briarproject.api.TransportId;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.PseudoRandom;
+import org.briarproject.api.keyagreement.KeyAgreementListener;
+import org.briarproject.api.keyagreement.TransportDescriptor;
 import org.briarproject.api.plugins.TransportConnectionReader;
 import org.briarproject.api.plugins.TransportConnectionWriter;
 import org.briarproject.api.plugins.duplex.DuplexPlugin;
@@ -158,6 +160,20 @@ class ModemPlugin implements DuplexPlugin, Modem.Callback {
 		throw new UnsupportedOperationException();
 	}
 
+	public boolean supportsKeyAgreement() {
+		return false;
+	}
+
+	public KeyAgreementListener createKeyAgreementListener(
+			byte[] commitment) {
+		throw new UnsupportedOperationException();
+	}
+
+	public DuplexTransportConnection createKeyAgreementConnection(
+			byte[] commitment, TransportDescriptor d, long timeout) {
+		throw new UnsupportedOperationException();
+	}
+
 	public void incomingCallConnected() {
 		LOG.info("Incoming call connected");
 		callback.incomingConnectionCreated(new ModemTransportConnection());
diff --git a/briar-tests/build.gradle b/briar-tests/build.gradle
index c5ae112372d62d09b74d1e6eb28c406c1ef32bf5..b3c2af66e1cda3509e1d645e778b820a55f89c92 100644
--- a/briar-tests/build.gradle
+++ b/briar-tests/build.gradle
@@ -15,6 +15,8 @@ dependencies {
 	compile project(':briar-desktop')
 	compile "junit:junit:4.12"
 	compile "org.jmock:jmock:2.8.1"
+	compile "org.jmock:jmock-junit4:2.8.1"
+	compile "org.jmock:jmock-legacy:2.8.1"
 	compile "org.hamcrest:hamcrest-library:1.3"
 	compile "org.hamcrest:hamcrest-core:1.3"
 }
@@ -23,6 +25,8 @@ dependencyVerification {
 	verify = [
 			'junit:junit:59721f0805e223d84b90677887d9ff567dc534d7c502ca903c0c2b17f05c116a',
 			'org.jmock:jmock:75d4bdaf636879f0215830c5e6ab99407069a625eaffde5d57b32d887b75dc14',
+			'org.jmock:jmock-junit4:81e3fff46ed56738a6f3f5147525d1d85cda591ce5df007cc193e735cee31113',
+			'org.jmock:jmock-legacy:19c76059eb254775ba884fc8039bc5c7d1700dc68cc55ad3be5b405a2a8a1819',
 			'org.hamcrest:hamcrest-library:711d64522f9ec410983bd310934296da134be4254a125080a0416ec178dfad1c',
 			'org.hamcrest:hamcrest-core:66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9',
 	]
diff --git a/briar-tests/src/org/briarproject/keyagreement/KeyAgreementProtocolTest.java b/briar-tests/src/org/briarproject/keyagreement/KeyAgreementProtocolTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..e404c547f48d009081a97493a4422f8cd0d9d200
--- /dev/null
+++ b/briar-tests/src/org/briarproject/keyagreement/KeyAgreementProtocolTest.java
@@ -0,0 +1,394 @@
+package org.briarproject.keyagreement;
+
+import org.briarproject.BriarTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyPair;
+import org.briarproject.api.crypto.PublicKey;
+import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.util.StringUtils;
+import org.jmock.Expectations;
+import org.jmock.auto.Mock;
+import org.jmock.integration.junit4.JUnitRuleMockery;
+import org.jmock.lib.legacy.ClassImposteriser;
+import org.junit.Rule;
+import org.junit.Test;
+
+import static org.briarproject.api.keyagreement.KeyAgreementConstants.COMMIT_LENGTH;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertThat;
+
+public class KeyAgreementProtocolTest extends BriarTestCase {
+
+	@Rule
+	public JUnitRuleMockery context = new JUnitRuleMockery() {{
+		// So we can mock concrete classes like KeyAgreementTransport
+		setImposteriser(ClassImposteriser.INSTANCE);
+	}};
+
+	private static final byte[] ALICE_PUBKEY = TestUtils.getRandomBytes(32);
+	private static final byte[] ALICE_COMMIT =
+			TestUtils.getRandomBytes(COMMIT_LENGTH);
+	private static final byte[] ALICE_PAYLOAD =
+			TestUtils.getRandomBytes(COMMIT_LENGTH + 8);
+
+	private static final byte[] BOB_PUBKEY = TestUtils.getRandomBytes(32);
+	private static final byte[] BOB_COMMIT =
+			TestUtils.getRandomBytes(COMMIT_LENGTH);
+	private static final byte[] BOB_PAYLOAD =
+			TestUtils.getRandomBytes(COMMIT_LENGTH + 19);
+
+	private static final byte[] ALICE_CONFIRM =
+			TestUtils.getRandomBytes(SecretKey.LENGTH);
+	private static final byte[] BOB_CONFIRM =
+			TestUtils.getRandomBytes(SecretKey.LENGTH);
+
+	private static final byte[] BAD_PUBKEY = TestUtils.getRandomBytes(32);
+	private static final byte[] BAD_COMMIT =
+			TestUtils.getRandomBytes(COMMIT_LENGTH);
+	private static final byte[] BAD_CONFIRM =
+			TestUtils.getRandomBytes(SecretKey.LENGTH);
+
+	@Mock
+	KeyAgreementProtocol.Callbacks callbacks;
+	@Mock
+	CryptoComponent crypto;
+	@Mock
+	PayloadEncoder payloadEncoder;
+	@Mock
+	KeyAgreementTransport transport;
+	@Mock
+	PublicKey ourPubKey;
+
+	@Test
+	public void testAliceProtocol() throws Exception {
+		// set up
+		final Payload theirPayload = new Payload(BOB_COMMIT, null);
+		final Payload ourPayload = new Payload(ALICE_COMMIT, null);
+		final KeyPair ourKeyPair = new KeyPair(ourPubKey, null);
+		final SecretKey sharedSecret = TestUtils.createSecretKey();
+		final SecretKey masterSecret = TestUtils.createSecretKey();
+
+		KeyAgreementProtocol protocol =
+				new KeyAgreementProtocol(callbacks, crypto, payloadEncoder,
+						transport, theirPayload, ourPayload, ourKeyPair, true);
+
+		// expectations
+		context.checking(new Expectations() {{
+			// Helpers
+			allowing(payloadEncoder).encode(ourPayload);
+			will(returnValue(ALICE_PAYLOAD));
+			allowing(payloadEncoder).encode(theirPayload);
+			will(returnValue(BOB_PAYLOAD));
+			allowing(ourPubKey).getEncoded();
+			will(returnValue(ALICE_PUBKEY));
+
+			// Alice sends her public key
+			oneOf(transport).sendKey(ALICE_PUBKEY);
+
+			// Alice receives Bob's public key
+			oneOf(callbacks).connectionWaiting();
+			oneOf(transport).receiveKey();
+			will(returnValue(BOB_PUBKEY));
+			oneOf(callbacks).initialPacketReceived();
+
+			// Alice verifies Bob's public key
+			oneOf(crypto).deriveKeyCommitment(BOB_PUBKEY);
+			will(returnValue(BOB_COMMIT));
+
+			// Alice computes shared secret
+			oneOf(crypto).deriveSharedSecret(BOB_PUBKEY, ourKeyPair, true);
+			will(returnValue(sharedSecret));
+
+			// Alice sends her confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, BOB_PAYLOAD,
+					ALICE_PAYLOAD, BOB_PUBKEY, ourKeyPair, true, true);
+			will(returnValue(ALICE_CONFIRM));
+			oneOf(transport).sendConfirm(ALICE_CONFIRM);
+
+			// Alice receives Bob's confirmation record
+			oneOf(transport).receiveConfirm();
+			will(returnValue(BOB_CONFIRM));
+
+			// Alice verifies Bob's confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, BOB_PAYLOAD,
+					ALICE_PAYLOAD, BOB_PUBKEY, ourKeyPair, true, false);
+			will(returnValue(BOB_CONFIRM));
+
+			// Alice computes master secret
+			oneOf(crypto).deriveMasterSecret(sharedSecret);
+			will(returnValue(masterSecret));
+		}});
+
+		// execute
+		assertThat(masterSecret, is(equalTo(protocol.perform())));
+	}
+
+	@Test
+	public void testBobProtocol() throws Exception {
+		// set up
+		final Payload theirPayload = new Payload(ALICE_COMMIT, null);
+		final Payload ourPayload = new Payload(BOB_COMMIT, null);
+		final KeyPair ourKeyPair = new KeyPair(ourPubKey, null);
+		final SecretKey sharedSecret = TestUtils.createSecretKey();
+		final SecretKey masterSecret = TestUtils.createSecretKey();
+
+		KeyAgreementProtocol protocol =
+				new KeyAgreementProtocol(callbacks, crypto, payloadEncoder,
+						transport, theirPayload, ourPayload, ourKeyPair, false);
+
+		// expectations
+		context.checking(new Expectations() {{
+			// Helpers
+			allowing(payloadEncoder).encode(ourPayload);
+			will(returnValue(BOB_PAYLOAD));
+			allowing(payloadEncoder).encode(theirPayload);
+			will(returnValue(ALICE_PAYLOAD));
+			allowing(ourPubKey).getEncoded();
+			will(returnValue(BOB_PUBKEY));
+
+			// Bob receives Alice's public key
+			oneOf(transport).receiveKey();
+			will(returnValue(ALICE_PUBKEY));
+			oneOf(callbacks).initialPacketReceived();
+
+			// Bob verifies Alice's public key
+			oneOf(crypto).deriveKeyCommitment(ALICE_PUBKEY);
+			will(returnValue(ALICE_COMMIT));
+
+			// Bob sends his public key
+			oneOf(transport).sendKey(BOB_PUBKEY);
+
+			// Bob computes shared secret
+			oneOf(crypto).deriveSharedSecret(ALICE_PUBKEY, ourKeyPair, false);
+			will(returnValue(sharedSecret));
+
+			// Bob receives Alices's confirmation record
+			oneOf(transport).receiveConfirm();
+			will(returnValue(ALICE_CONFIRM));
+
+			// Bob verifies Alice's confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, ALICE_PAYLOAD,
+					BOB_PAYLOAD, ALICE_PUBKEY, ourKeyPair, false, true);
+			will(returnValue(ALICE_CONFIRM));
+
+			// Bob sends his confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, ALICE_PAYLOAD,
+					BOB_PAYLOAD, ALICE_PUBKEY, ourKeyPair, false, false);
+			will(returnValue(BOB_CONFIRM));
+			oneOf(transport).sendConfirm(BOB_CONFIRM);
+
+			// Bob computes master secret
+			oneOf(crypto).deriveMasterSecret(sharedSecret);
+			will(returnValue(masterSecret));
+		}});
+
+		// execute
+		assertThat(masterSecret, is(equalTo(protocol.perform())));
+	}
+
+	@Test(expected = AbortException.class)
+	public void testAliceProtocolAbortOnBadKey() throws Exception {
+		// set up
+		final Payload theirPayload = new Payload(BOB_COMMIT, null);
+		final Payload ourPayload = new Payload(ALICE_COMMIT, null);
+		final KeyPair ourKeyPair = new KeyPair(ourPubKey, null);
+
+		KeyAgreementProtocol protocol =
+				new KeyAgreementProtocol(callbacks, crypto, payloadEncoder,
+						transport, theirPayload, ourPayload, ourKeyPair, true);
+
+		// expectations
+		context.checking(new Expectations() {{
+			// Helpers
+			allowing(ourPubKey).getEncoded();
+			will(returnValue(ALICE_PUBKEY));
+
+			// Alice sends her public key
+			oneOf(transport).sendKey(ALICE_PUBKEY);
+
+			// Alice receives a bad public key
+			oneOf(callbacks).connectionWaiting();
+			oneOf(transport).receiveKey();
+			will(returnValue(BAD_PUBKEY));
+			oneOf(callbacks).initialPacketReceived();
+
+			// Alice verifies Bob's public key
+			oneOf(crypto).deriveKeyCommitment(BAD_PUBKEY);
+			will(returnValue(BAD_COMMIT));
+
+			// Alice aborts
+			oneOf(transport).sendAbort(false);
+
+			// Alice never computes shared secret
+			never(crypto).deriveSharedSecret(BAD_PUBKEY, ourKeyPair, true);
+		}});
+
+		// execute
+		protocol.perform();
+	}
+
+	@Test(expected = AbortException.class)
+	public void testBobProtocolAbortOnBadKey() throws Exception {
+		// set up
+		final Payload theirPayload = new Payload(ALICE_COMMIT, null);
+		final Payload ourPayload = new Payload(BOB_COMMIT, null);
+		final KeyPair ourKeyPair = new KeyPair(ourPubKey, null);
+
+		KeyAgreementProtocol protocol =
+				new KeyAgreementProtocol(callbacks, crypto, payloadEncoder,
+						transport, theirPayload, ourPayload, ourKeyPair, false);
+
+		// expectations
+		context.checking(new Expectations() {{
+			// Helpers
+			allowing(ourPubKey).getEncoded();
+			will(returnValue(BOB_PUBKEY));
+
+			// Bob receives a bad public key
+			oneOf(transport).receiveKey();
+			will(returnValue(BAD_PUBKEY));
+			oneOf(callbacks).initialPacketReceived();
+
+			// Bob verifies Alice's public key
+			oneOf(crypto).deriveKeyCommitment(BAD_PUBKEY);
+			will(returnValue(BAD_COMMIT));
+
+			// Bob aborts
+			oneOf(transport).sendAbort(false);
+
+			// Bob never sends his public key
+			never(transport).sendKey(BOB_PUBKEY);
+		}});
+
+		// execute
+		protocol.perform();
+	}
+
+	@Test(expected = AbortException.class)
+	public void testAliceProtocolAbortOnBadConfirm() throws Exception {
+		// set up
+		final Payload theirPayload = new Payload(BOB_COMMIT, null);
+		final Payload ourPayload = new Payload(ALICE_COMMIT, null);
+		final KeyPair ourKeyPair = new KeyPair(ourPubKey, null);
+		final SecretKey sharedSecret = TestUtils.createSecretKey();
+
+		KeyAgreementProtocol protocol =
+				new KeyAgreementProtocol(callbacks, crypto, payloadEncoder,
+						transport, theirPayload, ourPayload, ourKeyPair, true);
+
+		// expectations
+		context.checking(new Expectations() {{
+			// Helpers
+			allowing(payloadEncoder).encode(ourPayload);
+			will(returnValue(ALICE_PAYLOAD));
+			allowing(payloadEncoder).encode(theirPayload);
+			will(returnValue(BOB_PAYLOAD));
+			allowing(ourPubKey).getEncoded();
+			will(returnValue(ALICE_PUBKEY));
+
+			// Alice sends her public key
+			oneOf(transport).sendKey(ALICE_PUBKEY);
+
+			// Alice receives Bob's public key
+			oneOf(callbacks).connectionWaiting();
+			oneOf(transport).receiveKey();
+			will(returnValue(BOB_PUBKEY));
+			oneOf(callbacks).initialPacketReceived();
+
+			// Alice verifies Bob's public key
+			oneOf(crypto).deriveKeyCommitment(BOB_PUBKEY);
+			will(returnValue(BOB_COMMIT));
+
+			// Alice computes shared secret
+			oneOf(crypto).deriveSharedSecret(BOB_PUBKEY, ourKeyPair, true);
+			will(returnValue(sharedSecret));
+
+			// Alice sends her confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, BOB_PAYLOAD,
+					ALICE_PAYLOAD, BOB_PUBKEY, ourKeyPair, true, true);
+			will(returnValue(ALICE_CONFIRM));
+			oneOf(transport).sendConfirm(ALICE_CONFIRM);
+
+			// Alice receives a bad confirmation record
+			oneOf(transport).receiveConfirm();
+			will(returnValue(BAD_CONFIRM));
+
+			// Alice verifies Bob's confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, BOB_PAYLOAD,
+					ALICE_PAYLOAD, BOB_PUBKEY, ourKeyPair, true, false);
+			will(returnValue(BOB_CONFIRM));
+
+			// Alice aborts
+			oneOf(transport).sendAbort(false);
+
+			// Alice never computes master secret
+			never(crypto).deriveMasterSecret(sharedSecret);
+		}});
+
+		// execute
+		protocol.perform();
+	}
+
+	@Test(expected = AbortException.class)
+	public void testBobProtocolAbortOnBadConfirm() throws Exception {
+		// set up
+		final Payload theirPayload = new Payload(ALICE_COMMIT, null);
+		final Payload ourPayload = new Payload(BOB_COMMIT, null);
+		final KeyPair ourKeyPair = new KeyPair(ourPubKey, null);
+		final SecretKey sharedSecret = TestUtils.createSecretKey();
+
+		KeyAgreementProtocol protocol =
+				new KeyAgreementProtocol(callbacks, crypto, payloadEncoder,
+						transport, theirPayload, ourPayload, ourKeyPair, false);
+
+		// expectations
+		context.checking(new Expectations() {{
+			// Helpers
+			allowing(payloadEncoder).encode(ourPayload);
+			will(returnValue(BOB_PAYLOAD));
+			allowing(payloadEncoder).encode(theirPayload);
+			will(returnValue(ALICE_PAYLOAD));
+			allowing(ourPubKey).getEncoded();
+			will(returnValue(BOB_PUBKEY));
+
+			// Bob receives Alice's public key
+			oneOf(transport).receiveKey();
+			will(returnValue(ALICE_PUBKEY));
+			oneOf(callbacks).initialPacketReceived();
+
+			// Bob verifies Alice's public key
+			oneOf(crypto).deriveKeyCommitment(ALICE_PUBKEY);
+			will(returnValue(ALICE_COMMIT));
+
+			// Bob sends his public key
+			oneOf(transport).sendKey(BOB_PUBKEY);
+
+			// Bob computes shared secret
+			oneOf(crypto).deriveSharedSecret(ALICE_PUBKEY, ourKeyPair, false);
+			will(returnValue(sharedSecret));
+
+			// Bob receives a bad confirmation record
+			oneOf(transport).receiveConfirm();
+			will(returnValue(BAD_CONFIRM));
+
+			// Bob verifies Alice's confirmation record
+			oneOf(crypto).deriveConfirmationRecord(sharedSecret, ALICE_PAYLOAD,
+					BOB_PAYLOAD, ALICE_PUBKEY, ourKeyPair, false, true);
+			will(returnValue(ALICE_CONFIRM));
+
+			// Bob aborts
+			oneOf(transport).sendAbort(false);
+
+			// Bob never sends his confirmation record
+			never(crypto).deriveConfirmationRecord(sharedSecret, ALICE_PAYLOAD,
+					BOB_PAYLOAD, ALICE_PUBKEY, ourKeyPair, false, false);
+		}});
+
+		// execute
+		protocol.perform();
+	}
+}
\ No newline at end of file