diff --git a/briar-android/src/org/briarproject/android/AndroidExecutorImpl.java b/briar-android/src/org/briarproject/android/AndroidExecutorImpl.java
index 1fb060d68ee870e19bc4a8d492f6cbcf602a76de..e46e7193bad9dfc7fc0ab75d7d3af109ed109897 100644
--- a/briar-android/src/org/briarproject/android/AndroidExecutorImpl.java
+++ b/briar-android/src/org/briarproject/android/AndroidExecutorImpl.java
@@ -1,87 +1,48 @@
 package org.briarproject.android;
 
-import java.util.concurrent.Callable;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.FutureTask;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.logging.Logger;
-
-import javax.inject.Inject;
-
-import org.briarproject.api.android.AndroidExecutor;
-
+import android.app.Application;
+import android.content.Context;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
 
-class AndroidExecutorImpl implements AndroidExecutor {
+import org.briarproject.api.android.AndroidExecutor;
 
-	private static final int SHUTDOWN = 0, RUN = 1;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
 
-	private static final Logger LOG =
-			Logger.getLogger(AndroidExecutorImpl.class.getName());
+import javax.inject.Inject;
 
-	private final Runnable loop;
-	private final AtomicBoolean started = new AtomicBoolean(false);
-	private final CountDownLatch startLatch = new CountDownLatch(1);
+class AndroidExecutorImpl implements AndroidExecutor {
 
-	private volatile Handler handler = null;
+	private final Handler handler;
 
 	@Inject
-	AndroidExecutorImpl() {
-		loop = new Runnable() {
-			public void run() {
-				Looper.prepare();
-				handler = new FutureTaskHandler();
-				startLatch.countDown();
-				Looper.loop();
-			}
-		};
-	}
-
-	private void startIfNecessary() {
-		if (started.getAndSet(true)) return;
-		new Thread(loop, "AndroidExecutor").start();
-		try {
-			startLatch.await();
-		} catch (InterruptedException e) {
-			LOG.warning("Interrupted while starting executor thread");
-			Thread.currentThread().interrupt();
-		}
+	AndroidExecutorImpl(Application app) {
+		Context ctx = app.getApplicationContext();
+		handler = new FutureTaskHandler(ctx.getMainLooper());
 	}
 
-	public <V> V call(Callable<V> c) throws InterruptedException,
-	ExecutionException {
-		startIfNecessary();
+	public <V> Future<V> submit(Callable<V> c) {
 		Future<V> f = new FutureTask<V>(c);
-		Message m = Message.obtain(handler, RUN, f);
-		handler.sendMessage(m);
-		return f.get();
+		handler.sendMessage(Message.obtain(handler, 0, f));
+		return f;
 	}
 
-	public void shutdown() {
-		if (handler != null) {
-			Message m = Message.obtain(handler, SHUTDOWN);
-			handler.sendMessage(m);
-		}
+	public void execute(Runnable r) {
+		handler.post(r);
 	}
 
 	private static class FutureTaskHandler extends Handler {
 
+		private FutureTaskHandler(Looper looper) {
+			super(looper);
+		}
+
 		@Override
 		public void handleMessage(Message m) {
-			switch(m.what) {
-			case SHUTDOWN:
-				Looper.myLooper().quit();
-				break;
-			case RUN:
-				((FutureTask<?>) m.obj).run();
-				break;
-			default:
-				throw new IllegalArgumentException();
-			}
+			((FutureTask<?>) m.obj).run();
 		}
 	}
 }
diff --git a/briar-android/src/org/briarproject/android/AndroidNotificationManagerImpl.java b/briar-android/src/org/briarproject/android/AndroidNotificationManagerImpl.java
index 44e3649bd2c924c9dabc2b9ac19b10a28c3cd443..8a5bcba15e5acf951ffe05faed623fa53803d873 100644
--- a/briar-android/src/org/briarproject/android/AndroidNotificationManagerImpl.java
+++ b/briar-android/src/org/briarproject/android/AndroidNotificationManagerImpl.java
@@ -14,23 +14,25 @@ import org.briarproject.android.contact.ConversationActivity;
 import org.briarproject.android.forum.ForumActivity;
 import org.briarproject.android.forum.ForumListActivity;
 import org.briarproject.api.Settings;
+import org.briarproject.api.android.AndroidExecutor;
 import org.briarproject.api.android.AndroidNotificationManager;
-import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DatabaseExecutor;
 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.MessageValidatedEvent;
 import org.briarproject.api.event.SettingsUpdatedEvent;
+import org.briarproject.api.forum.ForumManager;
+import org.briarproject.api.messaging.MessagingManager;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.util.StringUtils;
 
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Executor;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
 import java.util.logging.Logger;
 
 import javax.inject.Inject;
@@ -44,7 +46,7 @@ import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP;
 import static java.util.logging.Level.WARNING;
 
 class AndroidNotificationManagerImpl implements AndroidNotificationManager,
-EventListener {
+		EventListener {
 
 	private static final int PRIVATE_MESSAGE_NOTIFICATION_ID = 3;
 	private static final int FORUM_POST_NOTIFICATION_ID = 4;
@@ -59,27 +61,33 @@ EventListener {
 	private final DatabaseComponent db;
 	private final Executor dbExecutor;
 	private final EventBus eventBus;
+	private final MessagingManager messagingManager;
+	private final ForumManager forumManager;
+	private final AndroidExecutor androidExecutor;
 	private final Context appContext;
-	private final Lock lock = new ReentrantLock();
 
-	// The following are locking: lock
-	private final Map<ContactId, Integer> contactCounts =
-			new HashMap<ContactId, Integer>();
+	// The following must only be accessed on the main UI thread
+	private final Map<GroupId, Integer> contactCounts =
+			new HashMap<GroupId, Integer>();
 	private final Map<GroupId, Integer> forumCounts =
 			new HashMap<GroupId, Integer>();
 	private int contactTotal = 0, forumTotal = 0;
 	private int nextRequestId = 0;
-	private ContactId activeContact;
+	private GroupId visibleGroup = null;
 
 	private volatile Settings settings = new Settings();
 
 	@Inject
 	public AndroidNotificationManagerImpl(DatabaseComponent db,
 			@DatabaseExecutor Executor dbExecutor, EventBus eventBus,
-			Application app) {
+			MessagingManager messagingManager, ForumManager forumManager,
+			AndroidExecutor androidExecutor, Application app) {
 		this.db = db;
 		this.dbExecutor = dbExecutor;
 		this.eventBus = eventBus;
+		this.messagingManager = messagingManager;
+		this.forumManager = forumManager;
+		this.androidExecutor = androidExecutor;
 		appContext = app.getApplicationContext();
 	}
 
@@ -104,62 +112,71 @@ EventListener {
 
 	public boolean stop() {
 		eventBus.removeListener(this);
+		clearNotifications();
 		return true;
 	}
 
-	public void eventOccurred(Event e) {
-		if (e instanceof SettingsUpdatedEvent) loadSettings();
+	private void clearNotifications() {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				clearPrivateMessageNotification();
+				clearForumPostNotification();
+			}
+		});
 	}
 
-	public void showPrivateMessageNotification(ContactId c) {
-		lock.lock();
-		try {
-			// check first if user has this conversation open at the moment
-			if (activeContact == null || !activeContact.equals(c)) {
-				Integer count = contactCounts.get(c);
-				if (count == null) contactCounts.put(c, 1);
-				else contactCounts.put(c, count + 1);
-				contactTotal++;
-				updatePrivateMessageNotification();
-			}
-		} finally {
-			lock.unlock();
-		}
+	private void clearPrivateMessageNotification() {
+		Object o = appContext.getSystemService(NOTIFICATION_SERVICE);
+		NotificationManager nm = (NotificationManager) o;
+		nm.cancel(PRIVATE_MESSAGE_NOTIFICATION_ID);
 	}
 
-	public void clearPrivateMessageNotification(ContactId c) {
-		lock.lock();
-		try {
-			Integer count = contactCounts.remove(c);
-			if (count == null) return; // Already cleared
-			contactTotal -= count;
-			updatePrivateMessageNotification();
-		} finally {
-			lock.unlock();
-		}
+	private void clearForumPostNotification() {
+		Object o = appContext.getSystemService(NOTIFICATION_SERVICE);
+		NotificationManager nm = (NotificationManager) o;
+		nm.cancel(FORUM_POST_NOTIFICATION_ID);
 	}
 
-	public void blockPrivateMessageNotification(ContactId c) {
-		lock.lock();
-		try {
-			activeContact = c;
-		} finally {
-			lock.unlock();
+	public void eventOccurred(Event e) {
+		if (e instanceof SettingsUpdatedEvent) {
+			loadSettings();
+		} else if (e instanceof MessageValidatedEvent) {
+			MessageValidatedEvent m = (MessageValidatedEvent) e;
+			if (m.isValid() && !m.isLocal()) {
+				ClientId c = m.getClientId();
+				if (c.equals(messagingManager.getClientId()))
+					showPrivateMessageNotification(m.getMessage().getGroupId());
+				else if (c.equals(forumManager.getClientId()))
+					showForumPostNotification(m.getMessage().getGroupId());
+			}
 		}
 	}
 
-	public void unblockPrivateMessageNotification(ContactId c) {
-		lock.lock();
-		try {
-			if (activeContact != null && activeContact.equals(c)) {
-				activeContact = null;
+	public void showPrivateMessageNotification(final GroupId g) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				Integer count = contactCounts.get(g);
+				if (count == null) contactCounts.put(g, 1);
+				else contactCounts.put(g, count + 1);
+				contactTotal++;
+				if (!g.equals(visibleGroup))
+					updatePrivateMessageNotification();
 			}
-		} finally {
-			lock.unlock();
-		}
+		});
+	}
+
+	public void clearPrivateMessageNotification(final GroupId g) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				Integer count = contactCounts.remove(g);
+				if (count == null) return; // Already cleared
+				contactTotal -= count;
+				// FIXME: If the notification isn't showing, this may show it
+				updatePrivateMessageNotification();
+			}
+		});
 	}
 
-	// Locking: lock
 	private void updatePrivateMessageNotification() {
 		if (contactTotal == 0) {
 			clearPrivateMessageNotification();
@@ -180,9 +197,10 @@ EventListener {
 			b.setAutoCancel(true);
 			if (contactCounts.size() == 1) {
 				Intent i = new Intent(appContext, ConversationActivity.class);
-				ContactId c = contactCounts.keySet().iterator().next();
-				i.putExtra("briar.CONTACT_ID", c.getInt());
-				i.setData(Uri.parse(CONTACT_URI + "/" + c.getInt()));
+				GroupId g = contactCounts.keySet().iterator().next();
+				i.putExtra("briar.GROUP_ID", g.getBytes());
+				String idHex = StringUtils.toHexString(g.getBytes());
+				i.setData(Uri.parse(CONTACT_URI + "/" + idHex));
 				i.setFlags(FLAG_ACTIVITY_CLEAR_TOP | FLAG_ACTIVITY_SINGLE_TOP);
 				TaskStackBuilder t = TaskStackBuilder.create(appContext);
 				t.addParentStack(ConversationActivity.class);
@@ -202,13 +220,6 @@ EventListener {
 		}
 	}
 
-	// Locking: lock
-	private void clearPrivateMessageNotification() {
-		Object o = appContext.getSystemService(NOTIFICATION_SERVICE);
-		NotificationManager nm = (NotificationManager) o;
-		nm.cancel(PRIVATE_MESSAGE_NOTIFICATION_ID);
-	}
-
 	private int getDefaults() {
 		int defaults = DEFAULT_LIGHTS;
 		boolean sound = settings.getBoolean("notifySound", true);
@@ -220,32 +231,31 @@ EventListener {
 		return defaults;
 	}
 
-	public void showForumPostNotification(GroupId g) {
-		lock.lock();
-		try {
-			Integer count = forumCounts.get(g);
-			if (count == null) forumCounts.put(g, 1);
-			else forumCounts.put(g, count + 1);
-			forumTotal++;
-			updateForumPostNotification();
-		} finally {
-			lock.unlock();
-		}
+	public void showForumPostNotification(final GroupId g) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				Integer count = forumCounts.get(g);
+				if (count == null) forumCounts.put(g, 1);
+				else forumCounts.put(g, count + 1);
+				forumTotal++;
+				if (!g.equals(visibleGroup))
+					updateForumPostNotification();
+			}
+		});
 	}
 
-	public void clearForumPostNotification(GroupId g) {
-		lock.lock();
-		try {
-			Integer count = forumCounts.remove(g);
-			if (count == null) return; // Already cleared
-			forumTotal -= count;
-			updateForumPostNotification();
-		} finally {
-			lock.unlock();
-		}
+	public void clearForumPostNotification(final GroupId g) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				Integer count = forumCounts.remove(g);
+				if (count == null) return; // Already cleared
+				forumTotal -= count;
+				// FIXME: If the notification isn't showing, this may show it
+				updateForumPostNotification();
+			}
+		});
 	}
 
-	// Locking: lock
 	private void updateForumPostNotification() {
 		if (forumTotal == 0) {
 			clearForumPostNotification();
@@ -288,23 +298,19 @@ EventListener {
 		}
 	}
 
-	// Locking: lock
-	private void clearForumPostNotification() {
-		Object o = appContext.getSystemService(NOTIFICATION_SERVICE);
-		NotificationManager nm = (NotificationManager) o;
-		nm.cancel(FORUM_POST_NOTIFICATION_ID);
+	public void blockNotification(final GroupId g) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				visibleGroup = g;
+			}
+		});
 	}
 
-	public void clearNotifications() {
-		lock.lock();
-		try {
-			contactCounts.clear();
-			forumCounts.clear();
-			contactTotal = forumTotal = 0;
-			clearPrivateMessageNotification();
-			clearForumPostNotification();
-		} finally {
-			lock.unlock();
-		}
+	public void unblockNotification(final GroupId g) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				if (g.equals(visibleGroup)) visibleGroup = null;
+			}
+		});
 	}
 }
diff --git a/briar-android/src/org/briarproject/android/BaseActivity.java b/briar-android/src/org/briarproject/android/BaseActivity.java
index 138d3447ad744eacb2bd36b3424d56dcbc70fc4a..e8c3dd98a9fee80761e8340e5e185361402c9f5d 100644
--- a/briar-android/src/org/briarproject/android/BaseActivity.java
+++ b/briar-android/src/org/briarproject/android/BaseActivity.java
@@ -128,6 +128,7 @@ public abstract class BaseActivity extends AppCompatActivity
 		return scopedObjects;
 	}
 
+	// FIXME: Factor out prefs code so it can be used by SplashScreenActivity
 	private SharedPreferences getSharedPrefs() {
 		return getSharedPreferences(PREFS_DB, MODE_PRIVATE);
 	}
diff --git a/briar-android/src/org/briarproject/android/BriarService.java b/briar-android/src/org/briarproject/android/BriarService.java
index 366e342df3f9a290953ba5fa3c4958c477782f1c..90f774272b27d472ed7ad096141f3d3ce2a86d5b 100644
--- a/briar-android/src/org/briarproject/android/BriarService.java
+++ b/briar-android/src/org/briarproject/android/BriarService.java
@@ -11,22 +11,11 @@ import android.support.v4.app.NotificationCompat;
 
 import org.briarproject.R;
 import org.briarproject.api.android.AndroidExecutor;
-import org.briarproject.api.android.AndroidNotificationManager;
-import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DatabaseConfig;
-import org.briarproject.api.db.DatabaseExecutor;
-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.MessageAddedEvent;
 import org.briarproject.api.lifecycle.LifecycleManager;
 import org.briarproject.api.lifecycle.LifecycleManager.StartResult;
-import org.briarproject.api.messaging.MessagingManager;
-import org.briarproject.api.sync.GroupId;
 
 import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Logger;
 
@@ -34,6 +23,7 @@ import javax.inject.Inject;
 
 import roboguice.service.RoboService;
 
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
 import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP;
 import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
 import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP;
@@ -41,7 +31,7 @@ import static java.util.logging.Level.WARNING;
 import static org.briarproject.api.lifecycle.LifecycleManager.StartResult.ALREADY_RUNNING;
 import static org.briarproject.api.lifecycle.LifecycleManager.StartResult.SUCCESS;
 
-public class BriarService extends RoboService implements EventListener {
+public class BriarService extends RoboService {
 
 	private static final int ONGOING_NOTIFICATION_ID = 1;
 	private static final int FAILURE_NOTIFICATION_ID = 2;
@@ -53,14 +43,10 @@ public class BriarService extends RoboService implements EventListener {
 	private final Binder binder = new BriarBinder();
 
 	@Inject private DatabaseConfig databaseConfig;
-	@Inject private AndroidNotificationManager notificationManager;
 
 	// Fields that are accessed from background threads must be volatile
 	@Inject private volatile LifecycleManager lifecycleManager;
 	@Inject private volatile AndroidExecutor androidExecutor;
-	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
-	@Inject private volatile MessagingManager messagingManager;
-	@Inject private volatile EventBus eventBus;
 	private volatile boolean started = false;
 
 	@Override
@@ -95,7 +81,6 @@ public class BriarService extends RoboService implements EventListener {
 			public void run() {
 				StartResult result = lifecycleManager.startServices();
 				if (result == SUCCESS) {
-					eventBus.addListener(BriarService.this);
 					started = true;
 				} else if (result == ALREADY_RUNNING) {
 					LOG.info("Already running");
@@ -110,25 +95,34 @@ public class BriarService extends RoboService implements EventListener {
 		}.start();
 	}
 
-	private void showStartupFailureNotification(StartResult result) {
-		NotificationCompat.Builder b = new NotificationCompat.Builder(this);
-		b.setSmallIcon(android.R.drawable.stat_notify_error);
-		b.setContentTitle(getText(R.string.startup_failed_notification_title));
-		b.setContentText(getText(R.string.startup_failed_notification_text));
-		Intent i = new Intent(this, StartupFailureActivity.class);
-		i.setFlags(FLAG_ACTIVITY_NEW_TASK);
-		i.putExtra("briar.START_RESULT", result);
-		i.putExtra("briar.FAILURE_NOTIFICATION_ID", FAILURE_NOTIFICATION_ID);
-		b.setContentIntent(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT));
-		Object o = getSystemService(NOTIFICATION_SERVICE);
-		NotificationManager nm = (NotificationManager) o;
-		nm.notify(FAILURE_NOTIFICATION_ID, b.build());
-
-		// Bring the dashboard to the front to clear all other activities
-		i = new Intent(this, DashboardActivity.class);
-		i.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP);
-		i.putExtra("briar.STARTUP_FAILED", true);
-		startActivity(i);
+	private void showStartupFailureNotification(final StartResult result) {
+		androidExecutor.execute(new Runnable() {
+			public void run() {
+				NotificationCompat.Builder b =
+						new NotificationCompat.Builder(BriarService.this);
+				b.setSmallIcon(android.R.drawable.stat_notify_error);
+				b.setContentTitle(getText(
+						R.string.startup_failed_notification_title));
+				b.setContentText(getText(
+						R.string.startup_failed_notification_text));
+				Intent i = new Intent(BriarService.this,
+						StartupFailureActivity.class);
+				i.setFlags(FLAG_ACTIVITY_NEW_TASK);
+				i.putExtra("briar.START_RESULT", result);
+				i.putExtra("briar.FAILURE_NOTIFICATION_ID",
+						FAILURE_NOTIFICATION_ID);
+				b.setContentIntent(PendingIntent.getActivity(BriarService.this,
+						0, i, FLAG_UPDATE_CURRENT));
+				Object o = getSystemService(NOTIFICATION_SERVICE);
+				NotificationManager nm = (NotificationManager) o;
+				nm.notify(FAILURE_NOTIFICATION_ID, b.build());
+				// Bring the dashboard to the front to clear the back stack
+				i = new Intent(BriarService.this, DashboardActivity.class);
+				i.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP);
+				i.putExtra("briar.STARTUP_FAILED", true);
+				startActivity(i);
+			}
+		});
 	}
 
 	@Override
@@ -146,16 +140,11 @@ public class BriarService extends RoboService implements EventListener {
 		super.onDestroy();
 		LOG.info("Destroyed");
 		stopForeground(true);
-		notificationManager.clearNotifications();
 		// Stop the services in a background thread
 		new Thread() {
 			@Override
 			public void run() {
-				if (started) {
-					eventBus.removeListener(BriarService.this);
-					lifecycleManager.stopServices();
-				}
-				androidExecutor.shutdown();
+				if (started) lifecycleManager.stopServices();
 			}
 		}.start();
 	}
@@ -167,39 +156,6 @@ public class BriarService extends RoboService implements EventListener {
 		// FIXME: Work out what to do about it
 	}
 
-	public void eventOccurred(Event e) {
-		if (e instanceof MessageAddedEvent) {
-			MessageAddedEvent m = (MessageAddedEvent) e;
-			GroupId g = m.getGroupId();
-			ContactId c = m.getContactId();
-			if (c != null) showMessageNotification(g, c);
-		}
-	}
-
-	private void showMessageNotification(final GroupId g, final ContactId c) {
-		dbExecutor.execute(new Runnable() {
-			public void run() {
-				try {
-					lifecycleManager.waitForDatabase();
-					if (g.equals(messagingManager.getConversationId(c)))
-						notificationManager.showPrivateMessageNotification(c);
-					else notificationManager.showForumPostNotification(g);
-				} catch (DbException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-				} catch (InterruptedException e) {
-					LOG.info("Interruped while waiting for database");
-					Thread.currentThread().interrupt();
-				}
-			}
-		});
-	}
-
-	/** Waits for the database to be opened before returning. */
-	public void waitForDatabase() throws InterruptedException {
-		lifecycleManager.waitForDatabase();
-	}
-
 	/** Waits for all services to start before returning. */
 	public void waitForStartup() throws InterruptedException {
 		lifecycleManager.waitForStartup();
diff --git a/briar-android/src/org/briarproject/android/CrashReportActivity.java b/briar-android/src/org/briarproject/android/CrashReportActivity.java
index e412a1c41cb7832c3a87e3116b1c7d2e6c8e999b..bd7fad0bdc7f481cc7bd5c0389c98d6349de30d6 100644
--- a/briar-android/src/org/briarproject/android/CrashReportActivity.java
+++ b/briar-android/src/org/briarproject/android/CrashReportActivity.java
@@ -74,12 +74,12 @@ public class CrashReportActivity extends AppCompatActivity implements OnClickLis
 	private static final Logger LOG =
 			Logger.getLogger(CrashReportActivity.class.getName());
 
-	private final AndroidExecutor androidExecutor = new AndroidExecutorImpl();
+	private final AndroidExecutor androidExecutor =
+			new AndroidExecutorImpl(getApplication());
 
 	private ScrollView scroll = null;
 	private ListLoadingProgressBar progress = null;
 	private LinearLayout status = null;
-	private ImageButton share = null;
 	private File temp = null;
 
 	private volatile String stack = null;
@@ -120,7 +120,7 @@ public class CrashReportActivity extends AppCompatActivity implements OnClickLis
 			Resources res = getResources();
 			int background = res.getColor(R.color.button_bar_background);
 			footer.setBackgroundColor(background);
-			share = new ImageButton(this);
+			ImageButton share = new ImageButton(this);
 			share.setBackgroundResource(0);
 			share.setImageResource(R.drawable.social_share);
 			share.setOnClickListener(this);
@@ -323,11 +323,11 @@ public class CrashReportActivity extends AppCompatActivity implements OnClickLis
 		// Is Bluetooth available?
 		BluetoothAdapter bt = null;
 		try {
-			bt = androidExecutor.call(new Callable<BluetoothAdapter>() {
+			bt = androidExecutor.submit(new Callable<BluetoothAdapter>() {
 				public BluetoothAdapter call() throws Exception {
 					return BluetoothAdapter.getDefaultAdapter();
 				}
-			});
+			}).get();
 		} catch (InterruptedException e) {
 			LOG.warning("Interrupted while getting BluetoothAdapter");
 			Thread.currentThread().interrupt();
diff --git a/briar-android/src/org/briarproject/android/SplashScreenActivity.java b/briar-android/src/org/briarproject/android/SplashScreenActivity.java
index ffc926b1cfaf30dfa610c2a0f6f43dc58ef6276c..ddf3dbc23fc2f6457703f0d2231118b5c11c3a3b 100644
--- a/briar-android/src/org/briarproject/android/SplashScreenActivity.java
+++ b/briar-android/src/org/briarproject/android/SplashScreenActivity.java
@@ -16,8 +16,8 @@ import com.google.inject.Injector;
 import org.briarproject.R;
 import org.briarproject.android.util.LayoutUtils;
 import org.briarproject.api.db.DatabaseConfig;
+import org.briarproject.util.FileUtils;
 
-import java.io.File;
 import java.util.logging.Logger;
 
 import roboguice.RoboGuice;
@@ -86,8 +86,9 @@ public class SplashScreenActivity extends RoboSplashActivity {
 			if (hex != null && databaseConfig.databaseExists()) {
 				startActivity(new Intent(this, DashboardActivity.class));
 			} else {
-				prefs.edit().clear().commit();
-				delete(databaseConfig.getDatabaseDirectory());
+				prefs.edit().clear().apply();
+				FileUtils.deleteFileOrDir(
+						databaseConfig.getDatabaseDirectory());
 				startActivity(new Intent(this, SetupActivity.class));
 			}
 		}
@@ -106,17 +107,11 @@ public class SplashScreenActivity extends RoboSplashActivity {
 		}
 	}
 
-	private void delete(File f) {
-		if (f.isFile()) f.delete();
-		else if (f.isDirectory()) for (File child : f.listFiles()) delete(child);
-	}
-
 	private void setPreferencesDefaults() {
 		new Thread() {
 			@Override
 			public void run() {
-				PreferenceManager
-						.setDefaultValues(SplashScreenActivity.this,
+				PreferenceManager.setDefaultValues(SplashScreenActivity.this,
 								R.xml.panic_preferences, false);
 			}
 		}.start();
diff --git a/briar-android/src/org/briarproject/android/TestingActivity.java b/briar-android/src/org/briarproject/android/TestingActivity.java
index fb9b7fb912532e0ed0487864010a1156375ed5a0..c21d2076992de5d46287f3b3c43dc890050c7e0f 100644
--- a/briar-android/src/org/briarproject/android/TestingActivity.java
+++ b/briar-android/src/org/briarproject/android/TestingActivity.java
@@ -326,11 +326,11 @@ public class TestingActivity extends BriarActivity implements OnClickListener {
 		// Is Bluetooth available?
 		BluetoothAdapter bt = null;
 		try {
-			bt = androidExecutor.call(new Callable<BluetoothAdapter>() {
+			bt = androidExecutor.submit(new Callable<BluetoothAdapter>() {
 				public BluetoothAdapter call() throws Exception {
 					return BluetoothAdapter.getDefaultAdapter();
 				}
-			});
+			}).get();
 		} catch (InterruptedException e) {
 			LOG.warning("Interrupted while getting BluetoothAdapter");
 			Thread.currentThread().interrupt();
diff --git a/briar-android/src/org/briarproject/android/contact/ContactListActivity.java b/briar-android/src/org/briarproject/android/contact/ContactListActivity.java
index 6817730efad203890c0345c61437090378dce9bd..0d5e9005d2b54d57cc05da816417ff0d0506ba5c 100644
--- a/briar-android/src/org/briarproject/android/contact/ContactListActivity.java
+++ b/briar-android/src/org/briarproject/android/contact/ContactListActivity.java
@@ -3,7 +3,6 @@ package org.briarproject.android.contact;
 import android.content.Intent;
 import android.os.Bundle;
 import android.support.design.widget.FloatingActionButton;
-import android.support.v7.util.SortedList;
 import android.support.v7.widget.LinearLayoutManager;
 import android.view.View;
 
@@ -23,10 +22,11 @@ import org.briarproject.api.event.ContactRemovedEvent;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventBus;
 import org.briarproject.api.event.EventListener;
-import org.briarproject.api.event.MessageAddedEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.messaging.MessagingManager;
 import org.briarproject.api.messaging.PrivateMessageHeader;
 import org.briarproject.api.plugins.ConnectionRegistry;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.GroupId;
 
 import java.util.ArrayList;
@@ -36,6 +36,7 @@ import java.util.logging.Logger;
 
 import javax.inject.Inject;
 
+import static android.support.v7.util.SortedList.INVALID_POSITION;
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
 
@@ -105,18 +106,16 @@ public class ContactListActivity extends BriarActivity
 					for (Contact c : contactManager.getContacts()) {
 						try {
 							ContactId id = c.getId();
-							GroupId conversation =
+							GroupId groupId =
 									messagingManager.getConversationId(id);
 							Collection<PrivateMessageHeader> headers =
 									messagingManager.getMessageHeaders(id);
-
 							boolean connected =
 									connectionRegistry.isConnected(c.getId());
 							contacts.add(new ContactListItem(c, connected,
-									conversation,
-									headers));
+									groupId, headers));
 						} catch (NoSuchContactException e) {
-							// Continue
+							LOG.info("Contact removed");
 						}
 					}
 					displayContacts(contacts);
@@ -141,7 +140,7 @@ public class ContactListActivity extends BriarActivity
 						// sorting criteria and cause duplicates
 						for (ContactListItem contact : contacts) {
 							int position = adapter.findItemPosition(contact);
-							if (position == SortedList.INVALID_POSITION) {
+							if (position == INVALID_POSITION) {
 								adapter.add(contact);
 							} else {
 								adapter.updateItem(position, contact);
@@ -169,19 +168,22 @@ public class ContactListActivity extends BriarActivity
 		} else if (e instanceof ContactRemovedEvent) {
 			LOG.info("Contact removed");
 			removeItem(((ContactRemovedEvent) e).getContactId());
-		} else if (e instanceof MessageAddedEvent) {
-			LOG.info("Message added, reloading");
-			ContactId source = ((MessageAddedEvent) e).getContactId();
-			if (source == null) loadContacts();
-			else reloadContact(source);
+		} else if (e instanceof MessageValidatedEvent) {
+			MessageValidatedEvent m = (MessageValidatedEvent) e;
+			ClientId c = m.getClientId();
+			if (m.isValid() && c.equals(messagingManager.getClientId())) {
+				LOG.info("Message added, reloading");
+				reloadConversation(m.getMessage().getGroupId());
+			}
 		}
 	}
 
-	private void reloadContact(final ContactId c) {
+	private void reloadConversation(final GroupId g) {
 		runOnDbThread(new Runnable() {
 			public void run() {
 				try {
 					long now = System.currentTimeMillis();
+					ContactId c = messagingManager.getContactId(g);
 					Collection<PrivateMessageHeader> headers =
 							messagingManager.getMessageHeaders(c);
 					long duration = System.currentTimeMillis() - now;
@@ -189,7 +191,7 @@ public class ContactListActivity extends BriarActivity
 						LOG.info("Partial load took " + duration + " ms");
 					updateItem(c, headers);
 				} catch (NoSuchContactException e) {
-					removeItem(c);
+					LOG.info("Contact removed");
 				} catch (DbException e) {
 					if (LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
diff --git a/briar-android/src/org/briarproject/android/contact/ContactListAdapter.java b/briar-android/src/org/briarproject/android/contact/ContactListAdapter.java
index 970461d7088740c284a4e69016f907298bcb35b4..777349e687a630cc23e67cc9366de7998d6a3e28 100644
--- a/briar-android/src/org/briarproject/android/contact/ContactListAdapter.java
+++ b/briar-android/src/org/briarproject/android/contact/ContactListAdapter.java
@@ -14,11 +14,12 @@ import android.widget.TextView;
 
 import org.briarproject.R;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.sync.GroupId;
 
 import java.util.List;
 
+import static android.support.v7.util.SortedList.INVALID_POSITION;
+
 public class ContactListAdapter
 		extends RecyclerView.Adapter<ContactListAdapter.ContactHolder> {
 
@@ -130,17 +131,9 @@ public class ContactListAdapter
 		ui.layout.setOnClickListener(new View.OnClickListener() {
 			@Override
 			public void onClick(View v) {
-				ContactId contactId = item.getContact().getId();
-				String contactName = item.getContact().getAuthor().getName();
-				GroupId groupId = item.getConversationId();
-				AuthorId localAuthorId = item.getContact().getLocalAuthorId();
-
+				GroupId groupId = item.getGroupId();
 				Intent i = new Intent(ctx, ConversationActivity.class);
-				i.putExtra("briar.CONTACT_ID", contactId.getInt());
-				i.putExtra("briar.CONTACT_NAME", contactName);
 				i.putExtra("briar.GROUP_ID", groupId.getBytes());
-				i.putExtra("briar.LOCAL_AUTHOR_ID", localAuthorId.getBytes());
-
 				ctx.startActivity(i);
 			}
 		});
@@ -156,8 +149,7 @@ public class ContactListAdapter
 	}
 
 	public ContactListItem getItem(int position) {
-		if (position == SortedList.INVALID_POSITION ||
-				contacts.size() <= position) {
+		if (position == INVALID_POSITION || contacts.size() <= position) {
 			return null; // Not found
 		}
 		return contacts.get(position);
@@ -186,7 +178,7 @@ public class ContactListAdapter
 			ContactListItem item = getItem(i);
 			if (item.getContact().getId().equals(c)) return i;
 		}
-		return SortedList.INVALID_POSITION; // Not found
+		return INVALID_POSITION; // Not found
 	}
 
 	public void addAll(final List<ContactListItem> contacts) {
diff --git a/briar-android/src/org/briarproject/android/contact/ContactListItem.java b/briar-android/src/org/briarproject/android/contact/ContactListItem.java
index 2553c659c02e9c871115bf273a3a2eca3c97168d..2addb9e9d157a234a4554778e76099b7d7f3f636 100644
--- a/briar-android/src/org/briarproject/android/contact/ContactListItem.java
+++ b/briar-android/src/org/briarproject/android/contact/ContactListItem.java
@@ -10,15 +10,15 @@ import java.util.Collection;
 class ContactListItem {
 
 	private final Contact contact;
-	private final GroupId conversation;
+	private final GroupId groupId;
 	private boolean connected, empty;
 	private long timestamp;
 	private int unread;
 
-	ContactListItem(Contact contact, boolean connected, GroupId conversation,
+	ContactListItem(Contact contact, boolean connected, GroupId groupId,
 			Collection<PrivateMessageHeader> headers) {
 		this.contact = contact;
-		this.conversation = conversation;
+		this.groupId = groupId;
 		this.connected = connected;
 		setHeaders(headers);
 	}
@@ -39,8 +39,8 @@ class ContactListItem {
 		return contact;
 	}
 
-	GroupId getConversationId() {
-		return conversation;
+	GroupId getGroupId() {
+		return groupId;
 	}
 
 	boolean isConnected() {
diff --git a/briar-android/src/org/briarproject/android/contact/ConversationActivity.java b/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
index 18f24f9c992811be40f3a5eed8c7e016ea31ec1f..6b96389c8c8fbac2f62353b5c6c219fa5d618a4e 100644
--- a/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
+++ b/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
@@ -27,21 +27,19 @@ import org.briarproject.api.crypto.CryptoExecutor;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.NoSuchContactException;
 import org.briarproject.api.db.NoSuchMessageException;
-import org.briarproject.api.db.NoSuchSubscriptionException;
 import org.briarproject.api.event.ContactConnectedEvent;
 import org.briarproject.api.event.ContactDisconnectedEvent;
 import org.briarproject.api.event.ContactRemovedEvent;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventBus;
 import org.briarproject.api.event.EventListener;
-import org.briarproject.api.event.MessageAddedEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.event.MessagesAckedEvent;
 import org.briarproject.api.event.MessagesSentEvent;
 import org.briarproject.api.messaging.MessagingManager;
-import org.briarproject.api.messaging.PrivateConversation;
+import org.briarproject.api.messaging.PrivateMessage;
 import org.briarproject.api.messaging.PrivateMessageFactory;
 import org.briarproject.api.messaging.PrivateMessageHeader;
-import org.briarproject.api.messaging.PrivateMessageHeader.Status;
 import org.briarproject.api.plugins.ConnectionRegistry;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
@@ -66,8 +64,6 @@ import javax.inject.Inject;
 import static android.widget.Toast.LENGTH_SHORT;
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static org.briarproject.api.messaging.PrivateMessageHeader.Status.DELIVERED;
-import static org.briarproject.api.messaging.PrivateMessageHeader.Status.SENT;
 
 public class ConversationActivity extends BriarActivity
 		implements EventListener, OnClickListener {
@@ -89,20 +85,19 @@ public class ConversationActivity extends BriarActivity
 	@Inject private volatile MessagingManager messagingManager;
 	@Inject private volatile EventBus eventBus;
 	@Inject private volatile PrivateMessageFactory privateMessageFactory;
+	private volatile GroupId groupId = null;
 	private volatile ContactId contactId = null;
 	private volatile String contactName = null;
-	private volatile GroupId groupId = null;
-	private volatile PrivateConversation conversation = null;
-	private volatile boolean connected;
+	private volatile boolean connected = false;
 
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(state);
 
 		Intent i = getIntent();
-		int id = i.getIntExtra("briar.CONTACT_ID", -1);
-		if (id == -1) throw new IllegalStateException();
-		contactId = new ContactId(id);
+		byte[] b = i.getByteArrayExtra("briar.GROUP_ID");
+		if (b == null) throw new IllegalStateException();
+		groupId = new GroupId(b);
 
 		setContentView(R.layout.activity_conversation);
 
@@ -122,19 +117,17 @@ public class ConversationActivity extends BriarActivity
 	public void onResume() {
 		super.onResume();
 		eventBus.addListener(this);
-		notificationManager.blockPrivateMessageNotification(contactId);
-		loadContactAndGroup();
+		notificationManager.blockNotification(groupId);
+		notificationManager.clearPrivateMessageNotification(groupId);
+		loadContactDetails();
 		loadHeaders();
-
-		// remove the notification for this conversation since we see it now
-		notificationManager.clearPrivateMessageNotification(contactId);
 	}
 
 	@Override
 	public void onPause() {
 		super.onPause();
 		eventBus.removeListener(this);
-		notificationManager.unblockPrivateMessageNotification(contactId);
+		notificationManager.unblockNotification(groupId);
 		if (isFinishing()) markMessagesRead();
 	}
 
@@ -164,26 +157,21 @@ public class ConversationActivity extends BriarActivity
 		}
 	}
 
-	private void loadContactAndGroup() {
+	private void loadContactDetails() {
 		runOnDbThread(new Runnable() {
 			public void run() {
 				try {
 					long now = System.currentTimeMillis();
+					contactId = messagingManager.getContactId(groupId);
 					Contact contact = contactManager.getContact(contactId);
 					contactName = contact.getAuthor().getName();
-					groupId = messagingManager.getConversationId(contactId);
-					conversation = messagingManager.getConversation(groupId);
 					connected = connectionRegistry.isConnected(contactId);
 					long duration = System.currentTimeMillis() - now;
-					if (LOG.isLoggable(INFO)) {
-						LOG.info("Loading contact and conversation took "
-								+ duration + " ms");
-					}
+					if (LOG.isLoggable(INFO))
+						LOG.info("Loading contact took " + duration + " ms");
 					displayContactDetails();
 				} catch (NoSuchContactException e) {
 					finishOnUiThread();
-				} catch (NoSuchSubscriptionException e) {
-					finishOnUiThread();
 				} catch (DbException e) {
 					if (LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -234,7 +222,11 @@ public class ConversationActivity extends BriarActivity
 		runOnUiThread(new Runnable() {
 			public void run() {
 				sendButton.setEnabled(true);
-				if (!headers.isEmpty()) {
+				if (headers.isEmpty()) {
+					// we have no messages,
+					// so let the list know to hide progress bar
+					list.showData();
+				} else {
 					for (PrivateMessageHeader h : headers) {
 						ConversationItem item = new ConversationItem(h);
 						byte[] body = bodyCache.get(h.getId());
@@ -244,10 +236,6 @@ public class ConversationActivity extends BriarActivity
 					}
 					// Scroll to the bottom
 					list.scrollToPosition(adapter.getItemCount() - 1);
-				} else {
-					// we have no messages,
-					// so let the list know to hide progress bar
-					list.showData();
 				}
 			}
 		});
@@ -278,17 +266,13 @@ public class ConversationActivity extends BriarActivity
 			public void run() {
 				bodyCache.put(m, body);
 				int count = adapter.getItemCount();
-
 				for (int i = 0; i < count; i++) {
 					ConversationItem item = adapter.getItem(i);
-
 					if (item.getHeader().getId().equals(m)) {
 						item.setBody(body);
 						adapter.notifyItemChanged(i);
-
 						// Scroll to the bottom
 						list.scrollToPosition(count - 1);
-
 						return;
 					}
 				}
@@ -297,7 +281,6 @@ public class ConversationActivity extends BriarActivity
 	}
 
 	private void markMessagesRead() {
-		notificationManager.clearPrivateMessageNotification(contactId);
 		List<MessageId> unread = new ArrayList<MessageId>();
 		int count = adapter.getItemCount();
 		for (int i = 0; i < count; i++) {
@@ -335,35 +318,25 @@ public class ConversationActivity extends BriarActivity
 				LOG.info("Contact removed");
 				finishOnUiThread();
 			}
-		} else if (e instanceof MessageAddedEvent) {
-			MessageAddedEvent mEvent = (MessageAddedEvent) e;
-			GroupId g = mEvent.getGroupId();
-			if (g.equals(groupId)) {
-				// mark new incoming messages as read directly
-				if (mEvent.getContactId() != null) {
-					ConversationItem item = adapter.getLastItem();
-					if (item != null) {
-						markIncomingMessageRead(mEvent.getMessage(),
-								item.getHeader().getTimestamp());
-					}
-				}
-
+		} else if (e instanceof MessageValidatedEvent) {
+			MessageValidatedEvent m = (MessageValidatedEvent) e;
+			if (m.isValid() && m.getMessage().getGroupId().equals(groupId)) {
 				LOG.info("Message added, reloading");
-				// TODO: get and add the ConversationItem here to prevent
-				//       reloading the entire conversation
-				loadHeaders();
+				// Mark new incoming messages as read directly
+				if (m.isLocal()) loadHeaders();
+				else markMessageReadIfNew(m.getMessage());
 			}
 		} else if (e instanceof MessagesSentEvent) {
 			MessagesSentEvent m = (MessagesSentEvent) e;
 			if (m.getContactId().equals(contactId)) {
 				LOG.info("Messages sent");
-				markMessages(m.getMessageIds(), SENT);
+				markMessages(m.getMessageIds(), true, false);
 			}
 		} else if (e instanceof MessagesAckedEvent) {
 			MessagesAckedEvent m = (MessagesAckedEvent) e;
 			if (m.getContactId().equals(contactId)) {
 				LOG.info("Messages acked");
-				markMessages(m.getMessageIds(), DELIVERED);
+				markMessages(m.getMessageIds(), true, true);
 			}
 		} else if (e instanceof ContactConnectedEvent) {
 			ContactConnectedEvent c = (ContactConnectedEvent) e;
@@ -382,58 +355,55 @@ public class ConversationActivity extends BriarActivity
 		}
 	}
 
-	private void markMessages(final Collection<MessageId> messageIds,
-			final Status status) {
+	private void markMessageReadIfNew(final Message m) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				Set<MessageId> messages = new HashSet<MessageId>(messageIds);
-				int count = adapter.getItemCount();
-				for (int i = 0; i < count; i++) {
-					ConversationItem item = adapter.getItem(i);
-					if (messages.contains(item.getHeader().getId())) {
-						item.setStatus(status);
-						adapter.notifyItemChanged(i);
-					}
+				ConversationItem item = adapter.getLastItem();
+				if (item != null) {
+					// Mark the message read if it's the newest message
+					long lastMsgTime = item.getHeader().getTimestamp();
+					long newMsgTime = m.getTimestamp();
+					if (newMsgTime > lastMsgTime) markNewMessageRead(m);
+					else loadHeaders();
 				}
 			}
 		});
 	}
 
-	private void markIncomingMessageRead(final Message m,
-			final long lastMsgTime) {
-
-		// stop here if message is older than latest message we have
-		long newMsgTime = m.getTimestamp();
-		if (newMsgTime < lastMsgTime) return;
-
+	private void markNewMessageRead(final Message m) {
 		runOnDbThread(new Runnable() {
 			public void run() {
 				try {
-					// mark messages as read, because is latest
 					messagingManager.setReadFlag(m.getId(), true);
-					showIncomingMessageRead();
+					loadHeaders();
 				} catch (DbException e) {
 					if (LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
 				}
 			}
-			// TODO else: smooth-scroll up to unread messages if out of view
 		});
 	}
 
-	private void showIncomingMessageRead() {
+	private void markMessages(final Collection<MessageId> messageIds,
+			final boolean sent, final boolean seen) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				// this is only called from markIncomingMessageRead()
-				// so we can assume that it was the last message that changed
-				adapter.notifyItemChanged(adapter.getItemCount() - 1);
+				Set<MessageId> messages = new HashSet<MessageId>(messageIds);
+				int count = adapter.getItemCount();
+				for (int i = 0; i < count; i++) {
+					ConversationItem item = adapter.getItem(i);
+					if (messages.contains(item.getHeader().getId())) {
+						item.setSent(sent);
+						item.setSeen(seen);
+						adapter.notifyItemChanged(i);
+					}
+				}
 			}
 		});
 	}
 
 	public void onClick(View view) {
 		markMessagesRead();
-
 		String message = content.getText().toString();
 		if (message.equals("")) return;
 		long timestamp = System.currentTimeMillis();
@@ -445,21 +415,16 @@ public class ConversationActivity extends BriarActivity
 
 	private long getMinTimestampForNewMessage() {
 		// Don't use an earlier timestamp than the newest message
-		long timestamp = 0;
 		ConversationItem item = adapter.getLastItem();
-		if (item != null) {
-			timestamp = item.getHeader().getTimestamp();
-		}
-		return timestamp + 1;
+		return item == null ? 0 : item.getHeader().getTimestamp() + 1;
 	}
 
 	private void createMessage(final byte[] body, final long timestamp) {
 		cryptoExecutor.execute(new Runnable() {
 			public void run() {
 				try {
-					Message m = privateMessageFactory.createPrivateMessage(null,
-							conversation, "text/plain", timestamp, body);
-					storeMessage(m);
+					storeMessage(privateMessageFactory.createPrivateMessage(
+							groupId, timestamp, null, "text/plain", body));
 				} catch (GeneralSecurityException e) {
 					throw new RuntimeException(e);
 				} catch (IOException e) {
@@ -469,7 +434,7 @@ public class ConversationActivity extends BriarActivity
 		});
 	}
 
-	private void storeMessage(final Message m) {
+	private void storeMessage(final PrivateMessage m) {
 		runOnDbThread(new Runnable() {
 			public void run() {
 				try {
@@ -487,29 +452,20 @@ public class ConversationActivity extends BriarActivity
 	}
 
 	private void askToRemoveContact() {
-		runOnUiThread(new Runnable() {
-			@Override
-			public void run() {
-				DialogInterface.OnClickListener okListener =
-						new DialogInterface.OnClickListener() {
-							@Override
-							public void onClick(DialogInterface dialog,
-									int which) {
-								removeContact();
-							}
-						};
-
-				AlertDialog.Builder builder =
-						new AlertDialog.Builder(ConversationActivity.this);
-				builder.setTitle(
-						getString(R.string.dialog_title_delete_contact));
-				builder.setMessage(
-						getString(R.string.dialog_message_delete_contact));
-				builder.setPositiveButton(android.R.string.ok, okListener);
-				builder.setNegativeButton(android.R.string.cancel, null);
-				builder.show();
-			}
-		});
+		DialogInterface.OnClickListener okListener =
+				new DialogInterface.OnClickListener() {
+					@Override
+					public void onClick(DialogInterface dialog, int which) {
+						removeContact();
+					}
+				};
+		AlertDialog.Builder builder =
+				new AlertDialog.Builder(ConversationActivity.this);
+		builder.setTitle(getString(R.string.dialog_title_delete_contact));
+		builder.setMessage(getString(R.string.dialog_message_delete_contact));
+		builder.setPositiveButton(android.R.string.ok, okListener);
+		builder.setNegativeButton(android.R.string.cancel, null);
+		builder.show();
 	}
 
 	private void removeContact() {
diff --git a/briar-android/src/org/briarproject/android/contact/ConversationAdapter.java b/briar-android/src/org/briarproject/android/contact/ConversationAdapter.java
index 6b0c1aac62fd50392a13840f824b10edc6ccb768..96f61a4499b1cea9fb2944a71177376296cd4ce4 100644
--- a/briar-android/src/org/briarproject/android/contact/ConversationAdapter.java
+++ b/briar-android/src/org/briarproject/android/contact/ConversationAdapter.java
@@ -14,9 +14,6 @@ import org.briarproject.R;
 import org.briarproject.api.messaging.PrivateMessageHeader;
 import org.briarproject.util.StringUtils;
 
-import static org.briarproject.api.messaging.PrivateMessageHeader.Status.DELIVERED;
-import static org.briarproject.api.messaging.PrivateMessageHeader.Status.SENT;
-
 class ConversationAdapter extends
 		RecyclerView.Adapter<ConversationAdapter.MessageHolder> {
 
@@ -113,18 +110,18 @@ class ConversationAdapter extends
 		PrivateMessageHeader header = item.getHeader();
 
 		if (header.isLocal()) {
-			if (item.getStatus() == DELIVERED) {
+			if (item.isSeen()) {
 				ui.status.setImageResource(R.drawable.message_delivered);
-			} else if (item.getStatus() == SENT) {
+			} else if (item.isSent()) {
 				ui.status.setImageResource(R.drawable.message_sent);
 			} else {
 				ui.status.setImageResource(R.drawable.message_stored);
 			}
 		} else if (!header.isRead()) {
-			int bottom = ui.layout.getPaddingBottom();
+			int left = ui.layout.getPaddingLeft();
 			int top = ui.layout.getPaddingTop();
 			int right = ui.layout.getPaddingRight();
-			int left = ui.layout.getPaddingLeft();
+			int bottom = ui.layout.getPaddingBottom();
 
 			// show unread messages in different color to not miss them
 			ui.layout.setBackgroundResource(R.drawable.msg_in_unread);
@@ -185,7 +182,9 @@ class ConversationAdapter extends
 		this.messages.endBatchedUpdates();
 	}
 
+	// TODO: Does this class need to be public?
 	public static class MessageHolder extends RecyclerView.ViewHolder {
+
 		public ViewGroup layout;
 		public TextView body;
 		public TextView date;
diff --git a/briar-android/src/org/briarproject/android/contact/ConversationItem.java b/briar-android/src/org/briarproject/android/contact/ConversationItem.java
index 7ee6c3bd7d2bd61d00291aac18472506a77e6fd6..7603c5c8f19f0ee4dd064f616b3a737007ff3de5 100644
--- a/briar-android/src/org/briarproject/android/contact/ConversationItem.java
+++ b/briar-android/src/org/briarproject/android/contact/ConversationItem.java
@@ -1,19 +1,19 @@
 package org.briarproject.android.contact;
 
 import org.briarproject.api.messaging.PrivateMessageHeader;
-import org.briarproject.api.messaging.PrivateMessageHeader.Status;
 
 // This class is not thread-safe
 class ConversationItem {
 
 	private final PrivateMessageHeader header;
 	private byte[] body;
-	private Status status;
+	private boolean sent, seen;
 
 	ConversationItem(PrivateMessageHeader header) {
 		this.header = header;
 		body = null;
-		status = header.getStatus();
+		sent = header.isSent();
+		seen = header.isSeen();
 	}
 
 	PrivateMessageHeader getHeader() {
@@ -28,11 +28,19 @@ class ConversationItem {
 		this.body = body;
 	}
 
-	Status getStatus() {
-		return status;
+	boolean isSent() {
+		return sent;
 	}
 
-	void setStatus(Status status) {
-		this.status = status;
+	void setSent(boolean sent) {
+		this.sent = sent;
+	}
+
+	boolean isSeen() {
+		return seen;
+	}
+
+	void setSeen(boolean seen) {
+		this.seen = seen;
 	}
 }
diff --git a/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java b/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java
index 5fc968a961b66ced3bd4d6116fde70dbba19f07b..8ae263061408e2be827da4f176c3e0ef32de08a6 100644
--- a/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java
@@ -18,7 +18,6 @@ import org.briarproject.android.BriarActivity;
 import org.briarproject.android.util.LayoutUtils;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.forum.Forum;
-import org.briarproject.api.forum.ForumFactory;
 import org.briarproject.api.forum.ForumManager;
 import org.briarproject.util.StringUtils;
 
@@ -38,7 +37,7 @@ import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
 import static org.briarproject.android.util.CommonLayoutParams.MATCH_MATCH;
 import static org.briarproject.android.util.CommonLayoutParams.WRAP_WRAP;
-import static org.briarproject.api.sync.MessagingConstants.MAX_GROUP_NAME_LENGTH;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_NAME_LENGTH;
 
 public class CreateForumActivity extends BriarActivity
 implements OnEditorActionListener, OnClickListener {
@@ -52,7 +51,6 @@ implements OnEditorActionListener, OnClickListener {
 	private TextView feedback = null;
 
 	// Fields that are accessed from background threads must be volatile
-	@Inject private volatile ForumFactory forumFactory;
 	@Inject private volatile ForumManager forumManager;
 
 	@Override
@@ -115,8 +113,9 @@ implements OnEditorActionListener, OnClickListener {
 	}
 
 	private boolean validateName() {
-		int length = StringUtils.toUtf8(nameEntry.getText().toString()).length;
-		if (length > MAX_GROUP_NAME_LENGTH) {
+		String name = nameEntry.getText().toString();
+		int length = StringUtils.toUtf8(name).length;
+		if (length > MAX_FORUM_NAME_LENGTH) {
 			feedback.setText(R.string.name_too_long);
 			return false;
 		}
@@ -138,8 +137,8 @@ implements OnEditorActionListener, OnClickListener {
 		runOnDbThread(new Runnable() {
 			public void run() {
 				try {
-					Forum f = forumFactory.createForum(name);
 					long now = System.currentTimeMillis();
+					Forum f = forumManager.createForum(name);
 					forumManager.addForum(f);
 					long duration = System.currentTimeMillis() - now;
 					if (LOG.isLoggable(INFO))
diff --git a/briar-android/src/org/briarproject/android/forum/ForumActivity.java b/briar-android/src/org/briarproject/android/forum/ForumActivity.java
index 2916238f9a604b4d9cc15ea6e947964ce932dc8e..73706fad8997f20e07a834fd08073e65252f676b 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumActivity.java
@@ -24,7 +24,7 @@ import org.briarproject.api.db.NoSuchSubscriptionException;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventBus;
 import org.briarproject.api.event.EventListener;
-import org.briarproject.api.event.MessageAddedEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.event.SubscriptionRemovedEvent;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumManager;
@@ -56,7 +56,7 @@ import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP;
 import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP_1;
 
 public class ForumActivity extends BriarActivity implements EventListener,
-OnClickListener, OnItemClickListener {
+		OnClickListener, OnItemClickListener {
 
 	private static final int REQUEST_READ = 2;
 	private static final Logger LOG =
@@ -143,6 +143,8 @@ OnClickListener, OnItemClickListener {
 	public void onResume() {
 		super.onResume();
 		eventBus.addListener(this);
+		notificationManager.blockNotification(groupId);
+		notificationManager.clearForumPostNotification(groupId);
 		loadForum();
 		loadHeaders();
 	}
@@ -276,11 +278,11 @@ OnClickListener, OnItemClickListener {
 	public void onPause() {
 		super.onPause();
 		eventBus.removeListener(this);
+		notificationManager.unblockNotification(groupId);
 		if (isFinishing()) markPostsRead();
 	}
 
 	private void markPostsRead() {
-		notificationManager.clearForumPostNotification(groupId);
 		List<MessageId> unread = new ArrayList<MessageId>();
 		int count = adapter.getCount();
 		for (int i = 0; i < count; i++) {
@@ -312,8 +314,9 @@ OnClickListener, OnItemClickListener {
 	}
 
 	public void eventOccurred(Event e) {
-		if (e instanceof MessageAddedEvent) {
-			if (((MessageAddedEvent) e).getGroupId().equals(groupId)) {
+		if (e instanceof MessageValidatedEvent) {
+			MessageValidatedEvent m = (MessageValidatedEvent) e;
+			if (m.isValid() && m.getMessage().getGroupId().equals(groupId)) {
 				LOG.info("Message added, reloading");
 				loadHeaders();
 			}
diff --git a/briar-android/src/org/briarproject/android/forum/ForumListActivity.java b/briar-android/src/org/briarproject/android/forum/ForumListActivity.java
index fe1a08b7cde757d9c89799cc53ebcd6cd79bde1d..7ee1693a498cc0afc830626216b7f73dab18cd2d 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumListActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumListActivity.java
@@ -28,19 +28,18 @@ import org.briarproject.api.db.NoSuchSubscriptionException;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventBus;
 import org.briarproject.api.event.EventListener;
-import org.briarproject.api.event.MessageAddedEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.event.RemoteSubscriptionsUpdatedEvent;
 import org.briarproject.api.event.SubscriptionAddedEvent;
 import org.briarproject.api.event.SubscriptionRemovedEvent;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumManager;
 import org.briarproject.api.forum.ForumPostHeader;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 
 import java.util.Collection;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.logging.Logger;
 
 import javax.inject.Inject;
@@ -60,16 +59,13 @@ import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP;
 import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP_1;
 
 public class ForumListActivity extends BriarActivity
-implements EventListener, OnClickListener, OnItemClickListener,
-OnCreateContextMenuListener {
+		implements EventListener, OnClickListener, OnItemClickListener,
+		OnCreateContextMenuListener {
 
 	private static final int MENU_ITEM_UNSUBSCRIBE = 1;
 	private static final Logger LOG =
 			Logger.getLogger(ForumListActivity.class.getName());
 
-	private final Map<GroupId, GroupId> groupIds =
-			new ConcurrentHashMap<GroupId, GroupId>();
-
 	private TextView empty = null;
 	private ForumListAdapter adapter = null;
 	private ListView list = null;
@@ -179,7 +175,6 @@ OnCreateContextMenuListener {
 	private void clearHeaders() {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				groupIds.clear();
 				empty.setVisibility(GONE);
 				list.setVisibility(GONE);
 				available.setVisibility(GONE);
@@ -194,12 +189,10 @@ OnCreateContextMenuListener {
 			final Collection<ForumPostHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				GroupId id = f.getId();
-				groupIds.put(id, id);
 				list.setVisibility(VISIBLE);
 				loading.setVisibility(GONE);
 				// Remove the old item, if any
-				ForumListItem item = findForum(id);
+				ForumListItem item = findForum(f.getId());
 				if (item != null) adapter.remove(item);
 				// Add a new item
 				adapter.add(new ForumListItem(f, headers));
@@ -255,11 +248,12 @@ OnCreateContextMenuListener {
 	}
 
 	public void eventOccurred(Event e) {
-		if (e instanceof MessageAddedEvent) {
-			GroupId g = ((MessageAddedEvent) e).getGroupId();
-			if (groupIds.containsKey(g)) {
+		if (e instanceof MessageValidatedEvent) {
+			MessageValidatedEvent m = (MessageValidatedEvent) e;
+			ClientId c = m.getClientId();
+			if (m.isValid() && c.equals(forumManager.getClientId())) {
 				LOG.info("Message added, reloading");
-				loadHeaders(g);
+				loadHeaders(m.getMessage().getGroupId());
 			}
 		} else if (e instanceof RemoteSubscriptionsUpdatedEvent) {
 			LOG.info("Remote subscriptions changed, reloading");
@@ -269,7 +263,7 @@ OnCreateContextMenuListener {
 			loadHeaders();
 		} else if (e instanceof SubscriptionRemovedEvent) {
 			Group g = ((SubscriptionRemovedEvent) e).getGroup();
-			if (groupIds.containsKey(g.getId())) {
+			if (g.getClientId().equals(forumManager.getClientId())) {
 				LOG.info("Group removed, reloading");
 				loadHeaders();
 			}
@@ -303,7 +297,6 @@ OnCreateContextMenuListener {
 			public void run() {
 				ForumListItem item = findForum(g);
 				if (item != null) {
-					groupIds.remove(g);
 					adapter.remove(item);
 					adapter.notifyDataSetChanged();
 					if (adapter.isEmpty()) {
diff --git a/briar-android/src/org/briarproject/android/forum/WriteForumPostActivity.java b/briar-android/src/org/briarproject/android/forum/WriteForumPostActivity.java
index c3346813c5d010d81ff9ef459d25654b2c32fcde..a29fade64a183f139cc3e76dca1d3abf7022feb7 100644
--- a/briar-android/src/org/briarproject/android/forum/WriteForumPostActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/WriteForumPostActivity.java
@@ -30,12 +30,12 @@ import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumManager;
+import org.briarproject.api.forum.ForumPost;
 import org.briarproject.api.forum.ForumPostFactory;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 import org.briarproject.util.StringUtils;
 
@@ -267,35 +267,35 @@ implements OnItemSelectedListener, OnClickListener {
 				// Don't use an earlier timestamp than the newest post
 				long timestamp = System.currentTimeMillis();
 				timestamp = Math.max(timestamp, minTimestamp);
-				Message m;
+				ForumPost p;
 				try {
 					if (localAuthor == null) {
-						m = forumPostFactory.createAnonymousPost(parentId,
-								forum, "text/plain", timestamp, body);
+						p = forumPostFactory.createAnonymousPost(groupId,
+								timestamp, parentId, "text/plain", body);
 					} else {
 						KeyParser keyParser = crypto.getSignatureKeyParser();
 						byte[] b = localAuthor.getPrivateKey();
 						PrivateKey authorKey = keyParser.parsePrivateKey(b);
-						m = forumPostFactory.createPseudonymousPost(parentId,
-								forum, localAuthor, authorKey, "text/plain",
-								timestamp, body);
+						p = forumPostFactory.createPseudonymousPost(groupId,
+								timestamp, parentId, localAuthor, "text/plain",
+								body, authorKey);
 					}
 				} catch (GeneralSecurityException e) {
 					throw new RuntimeException(e);
 				} catch (IOException e) {
 					throw new RuntimeException(e);
 				}
-				storePost(m);
+				storePost(p);
 			}
 		});
 	}
 
-	private void storePost(final Message m) {
+	private void storePost(final ForumPost p) {
 		runOnDbThread(new Runnable() {
 			public void run() {
 				try {
 					long now = System.currentTimeMillis();
-					forumManager.addLocalPost(m);
+					forumManager.addLocalPost(p);
 					long duration = System.currentTimeMillis() - now;
 					if (LOG.isLoggable(INFO))
 						LOG.info("Storing message took " + duration + " ms");
diff --git a/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java b/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java
index ae5d0dd409779caa0ef198ec9995dcb71b1d9422..0d622ef7791e050dcd03f0d828c5d8193a1b7dfa 100644
--- a/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java
+++ b/briar-android/src/org/briarproject/plugins/droidtooth/DroidtoothPlugin.java
@@ -110,11 +110,11 @@ class DroidtoothPlugin implements DuplexPlugin {
 		// BluetoothAdapter.getDefaultAdapter() must be called on a thread
 		// with a message queue, so submit it to the AndroidExecutor
 		try {
-			adapter = androidExecutor.call(new Callable<BluetoothAdapter>() {
+			adapter = androidExecutor.submit(new Callable<BluetoothAdapter>() {
 				public BluetoothAdapter call() throws Exception {
 					return BluetoothAdapter.getDefaultAdapter();
 				}
-			});
+			}).get();
 		} catch (InterruptedException e) {
 			Thread.currentThread().interrupt();
 			throw new IOException("Interrupted while getting BluetoothAdapter");
diff --git a/briar-api/src/org/briarproject/api/UniqueId.java b/briar-api/src/org/briarproject/api/UniqueId.java
index 0c690ff2fd567ab4c7848fa625ea4a03f048697a..9602a5fa4d98a65379e3f3c9d776ee69484e6a06 100644
--- a/briar-api/src/org/briarproject/api/UniqueId.java
+++ b/briar-api/src/org/briarproject/api/UniqueId.java
@@ -1,6 +1,7 @@
 package org.briarproject.api;
 
 import java.util.Arrays;
+import java.util.Comparator;
 
 public abstract class UniqueId {
 
@@ -27,4 +28,20 @@ public abstract class UniqueId {
 		if (hashCode == -1) hashCode = Arrays.hashCode(id);
 		return hashCode;
 	}
+
+	public static class IdComparator implements Comparator<UniqueId> {
+
+		public static final IdComparator INSTANCE = new IdComparator();
+
+		@Override
+		public int compare(UniqueId a, UniqueId b) {
+			byte[] aBytes = a.getBytes(), bBytes = b.getBytes();
+			for (int i = 0; i < UniqueId.LENGTH; i++) {
+				int aUnsigned = aBytes[i] & 0xFF, bUnsigned = bBytes[i] & 0xFF;
+				if (aUnsigned < bUnsigned) return -1;
+				if (aUnsigned > bUnsigned) return 1;
+			}
+			return 0;
+		}
+	}
 }
diff --git a/briar-api/src/org/briarproject/api/android/AndroidExecutor.java b/briar-api/src/org/briarproject/api/android/AndroidExecutor.java
index 4f964d6a12b50176f7b8033526ee201ec9c841e0..635702d0154d9a1ffe85ee8644c85dbd47071dba 100644
--- a/briar-api/src/org/briarproject/api/android/AndroidExecutor.java
+++ b/briar-api/src/org/briarproject/api/android/AndroidExecutor.java
@@ -1,7 +1,7 @@
 package org.briarproject.api.android;
 
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
 
 /**
  * Enables background threads to make Android API calls that must be made from
@@ -10,10 +10,11 @@ import java.util.concurrent.ExecutionException;
 public interface AndroidExecutor {
 
 	/**
-	 * Runs the given task on a thread with a message queue and returns the
-	 * result of the task.
+	 * Runs the given task on the main UI thread and returns a Future for
+	 * getting the result.
 	 */
-	<V> V call(Callable<V> c) throws InterruptedException, ExecutionException;
+	<V> Future<V> submit(Callable<V> c);
 
-	void shutdown();
+	/** Runs the given task on the main UI thread. */
+	void execute(Runnable r);
 }
diff --git a/briar-api/src/org/briarproject/api/android/AndroidNotificationManager.java b/briar-api/src/org/briarproject/api/android/AndroidNotificationManager.java
index d51c6e3f7b5ccf90c37c7a8e5946c4e4505b9bb7..ed3223c4b7652ec44f31e6674399efaa6de7bb97 100644
--- a/briar-api/src/org/briarproject/api/android/AndroidNotificationManager.java
+++ b/briar-api/src/org/briarproject/api/android/AndroidNotificationManager.java
@@ -1,26 +1,20 @@
 package org.briarproject.api.android;
 
-import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.lifecycle.Service;
 import org.briarproject.api.sync.GroupId;
 
-/**
- * Manages notifications for private messages and group posts. All methods must
- * be called from the Android UI thread.
- */
+/** Manages notifications for private messages and forum posts. */
 public interface AndroidNotificationManager extends Service {
 
-	void showPrivateMessageNotification(ContactId c);
+	void showPrivateMessageNotification(GroupId g);
 
-	void clearPrivateMessageNotification(ContactId c);
-
-	void blockPrivateMessageNotification(ContactId c);
-
-	void unblockPrivateMessageNotification(ContactId c);
+	void clearPrivateMessageNotification(GroupId g);
 
 	void showForumPostNotification(GroupId g);
 
 	void clearForumPostNotification(GroupId g);
 
-	void clearNotifications();
+	void blockNotification(GroupId g);
+
+	void unblockNotification(GroupId g);
 }
diff --git a/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java b/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java
index 3a69a4e79144cddbe9158b67bc8ded4b2e866cc6..836628c906a9cd997cf300ab10711454876aaa42 100644
--- a/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java
+++ b/briar-api/src/org/briarproject/api/crypto/CryptoComponent.java
@@ -57,9 +57,6 @@ public interface CryptoComponent {
 	 */
 	byte[] deriveSignatureNonce(SecretKey master, boolean alice);
 
-	/** Derives a group salt from the given master secret. */
-	byte[] deriveGroupSalt(SecretKey master);
-
 	/**
 	 * Derives initial transport keys for the given transport in the given
 	 * rotation period from the given master secret.
@@ -77,6 +74,12 @@ public interface CryptoComponent {
 	/** Encodes the pseudo-random tag that is used to recognise a stream. */
 	void encodeTag(byte[] tag, SecretKey tagKey, long streamNumber);
 
+	/**
+	 * Returns the hash of the given inputs. The inputs are unambiguously
+	 * combined by prefixing each input with its length.
+	 */
+	byte[] hash(byte[]... inputs);
+
 	/**
 	 * Encrypts and authenticates the given plaintext so it can be written to
 	 * storage. The encryption and authentication keys are derived from the
diff --git a/briar-api/src/org/briarproject/api/data/BdfDictionary.java b/briar-api/src/org/briarproject/api/data/BdfDictionary.java
index eecea996c123ed0dfb4ca4ef42987531362b3c22..54a20f6f51b71fffb9f63e5d6b4c1c8e5430d7b3 100644
--- a/briar-api/src/org/briarproject/api/data/BdfDictionary.java
+++ b/briar-api/src/org/briarproject/api/data/BdfDictionary.java
@@ -1,9 +1,18 @@
 package org.briarproject.api.data;
 
-import java.util.HashMap;
+import org.briarproject.api.FormatException;
 
-// This class is not thread-safe
-public class BdfDictionary extends HashMap<String, Object> {
+import java.util.Hashtable;
+
+public class BdfDictionary extends Hashtable<String, Object> {
+
+	public static final Object NULL_VALUE = new Object();
+
+	public Boolean getBoolean(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof Boolean) return (Boolean) o;
+		throw new FormatException();
+	}
 
 	public Boolean getBoolean(String key, Boolean defaultValue) {
 		Object o = get(key);
@@ -11,36 +20,72 @@ public class BdfDictionary extends HashMap<String, Object> {
 		return defaultValue;
 	}
 
+	public Long getInteger(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof Long) return (Long) o;
+		throw new FormatException();
+	}
+
 	public Long getInteger(String key, Long defaultValue) {
 		Object o = get(key);
 		if (o instanceof Long) return (Long) o;
 		return defaultValue;
 	}
 
+	public Double getFloat(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof Double) return (Double) o;
+		throw new FormatException();
+	}
+
 	public Double getFloat(String key, Double defaultValue) {
 		Object o = get(key);
 		if (o instanceof Double) return (Double) o;
 		return defaultValue;
 	}
 
+	public String getString(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof String) return (String) o;
+		throw new FormatException();
+	}
+
 	public String getString(String key, String defaultValue) {
 		Object o = get(key);
 		if (o instanceof String) return (String) o;
 		return defaultValue;
 	}
 
+	public byte[] getRaw(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof byte[]) return (byte[]) o;
+		throw new FormatException();
+	}
+
 	public byte[] getRaw(String key, byte[] defaultValue) {
 		Object o = get(key);
 		if (o instanceof byte[]) return (byte[]) o;
 		return defaultValue;
 	}
 
+	public BdfList getList(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof BdfList) return (BdfList) o;
+		throw new FormatException();
+	}
+
 	public BdfList getList(String key, BdfList defaultValue) {
 		Object o = get(key);
 		if (o instanceof BdfList) return (BdfList) o;
 		return defaultValue;
 	}
 
+	public BdfDictionary getDictionary(String key) throws FormatException {
+		Object o = get(key);
+		if (o instanceof BdfDictionary) return (BdfDictionary) o;
+		throw new FormatException();
+	}
+
 	public BdfDictionary getDictionary(String key, BdfDictionary defaultValue) {
 		Object o = get(key);
 		if (o instanceof BdfDictionary) return (BdfDictionary) o;
diff --git a/briar-api/src/org/briarproject/api/data/BdfList.java b/briar-api/src/org/briarproject/api/data/BdfList.java
index 949d414676c10f27d6824df29841cbeb573beb49..2caa597ec65103c4da429867c878bfa82ca0ac16 100644
--- a/briar-api/src/org/briarproject/api/data/BdfList.java
+++ b/briar-api/src/org/briarproject/api/data/BdfList.java
@@ -1,9 +1,16 @@
 package org.briarproject.api.data;
 
-import java.util.ArrayList;
+import org.briarproject.api.FormatException;
 
-// This class is not thread-safe
-public class BdfList extends ArrayList<Object> {
+import java.util.Vector;
+
+public class BdfList extends Vector<Object> {
+
+	public Boolean getBoolean(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof Boolean) return (Boolean) o;
+		throw new FormatException();
+	}
 
 	public Boolean getBoolean(int index, Boolean defaultValue) {
 		Object o = get(index);
@@ -11,36 +18,72 @@ public class BdfList extends ArrayList<Object> {
 		return defaultValue;
 	}
 
+	public Long getInteger(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof Long) return (Long) o;
+		throw new FormatException();
+	}
+
 	public Long getInteger(int index, Long defaultValue) {
 		Object o = get(index);
 		if (o instanceof Long) return (Long) o;
 		return defaultValue;
 	}
 
+	public Double getFloat(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof Double) return (Double) o;
+		throw new FormatException();
+	}
+
 	public Double getFloat(int index, Double defaultValue) {
 		Object o = get(index);
 		if (o instanceof Double) return (Double) o;
 		return defaultValue;
 	}
 
+	public String getString(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof String) return (String) o;
+		throw new FormatException();
+	}
+
 	public String getString(int index, String defaultValue) {
 		Object o = get(index);
 		if (o instanceof String) return (String) o;
 		return defaultValue;
 	}
 
+	public byte[] getRaw(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof byte[]) return (byte[]) o;
+		throw new FormatException();
+	}
+
 	public byte[] getRaw(int index, byte[] defaultValue) {
 		Object o = get(index);
 		if (o instanceof byte[]) return (byte[]) o;
 		return defaultValue;
 	}
 
+	public BdfList getList(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof BdfList) return (BdfList) o;
+		throw new FormatException();
+	}
+
 	public BdfList getList(int index, BdfList defaultValue) {
 		Object o = get(index);
 		if (o instanceof BdfList) return (BdfList) o;
 		return defaultValue;
 	}
 
+	public BdfDictionary getDictionary(int index) throws FormatException {
+		Object o = get(index);
+		if (o instanceof BdfDictionary) return (BdfDictionary) o;
+		throw new FormatException();
+	}
+
 	public BdfDictionary getDictionary(int index, BdfDictionary defaultValue) {
 		Object o = get(index);
 		if (o instanceof BdfDictionary) return (BdfDictionary) o;
diff --git a/briar-api/src/org/briarproject/api/data/BdfReader.java b/briar-api/src/org/briarproject/api/data/BdfReader.java
index d279fe7e11b4b3b4356c9ef64b01ef9d9c145bb7..813dc86c942fbeed7e347da84e0bef9b01e6dddb 100644
--- a/briar-api/src/org/briarproject/api/data/BdfReader.java
+++ b/briar-api/src/org/briarproject/api/data/BdfReader.java
@@ -7,9 +7,6 @@ public interface BdfReader {
 	boolean eof() throws IOException;
 	void close() throws IOException;
 
-	void addConsumer(Consumer c);
-	void removeConsumer(Consumer c);
-
 	boolean hasNull() throws IOException;
 	void readNull() throws IOException;
 	void skipNull() throws IOException;
diff --git a/briar-api/src/org/briarproject/api/data/DataConstants.java b/briar-api/src/org/briarproject/api/data/DataConstants.java
deleted file mode 100644
index 7ab04132e8ec884e62588983c7f2203dbe0e3237..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/data/DataConstants.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.briarproject.api.data;
-
-import org.briarproject.api.UniqueId;
-
-public interface DataConstants {
-
-	int LIST_START_LENGTH = 1;
-
-	int LIST_END_LENGTH = 1;
-
-	int UNIQUE_ID_LENGTH = 2 + UniqueId.LENGTH;
-}
diff --git a/briar-api/src/org/briarproject/api/db/DatabaseComponent.java b/briar-api/src/org/briarproject/api/db/DatabaseComponent.java
index 086689409b835ed413541fde6dffa092d6bc0dd9..84cca32ea7d2333d80d60b4b7a4b3654ce5face5 100644
--- a/briar-api/src/org/briarproject/api/db/DatabaseComponent.java
+++ b/briar-api/src/org/briarproject/api/db/DatabaseComponent.java
@@ -9,11 +9,12 @@ import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.Ack;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.Request;
 import org.briarproject.api.sync.SubscriptionAck;
@@ -44,9 +45,12 @@ public interface DatabaseComponent {
 	 */
 	ContactId addContact(Author remote, AuthorId local) throws DbException;
 
+	/** Adds a group to the given contact's subscriptions. */
+	void addContactGroup(ContactId c, Group g) throws DbException;
+
 	/**
 	 * Subscribes to a group, or returns false if the user already has the
-	 * maximum number of public subscriptions.
+	 * maximum number of subscriptions.
 	 */
 	boolean addGroup(Group g) throws DbException;
 
@@ -54,7 +58,8 @@ public interface DatabaseComponent {
 	void addLocalAuthor(LocalAuthor a) throws DbException;
 
 	/** Stores a local message. */
-	void addLocalMessage(Message m) throws DbException;
+	void addLocalMessage(Message m, ClientId c, Metadata meta)
+			throws DbException;
 
 	/**
 	 * Stores a transport and returns true if the transport was not previously
@@ -135,8 +140,11 @@ public interface DatabaseComponent {
 	Collection<TransportUpdate> generateTransportUpdates(ContactId c,
 			int maxLatency) throws DbException;
 
-	/** Returns all groups to which the user could subscribe. */
-	Collection<Group> getAvailableGroups() throws DbException;
+	/**
+	 * Returns all groups belonging to the given client to which the user could
+	 * subscribe.
+	 */
+	Collection<Group> getAvailableGroups(ClientId c) throws DbException;
 
 	/** Returns the contact with the given ID. */
 	Contact getContact(ContactId c) throws DbException;
@@ -147,21 +155,11 @@ public interface DatabaseComponent {
 	/** Returns the group with the given ID, if the user subscribes to it. */
 	Group getGroup(GroupId g) throws DbException;
 
-	/** Returns all groups to which the user subscribes, excluding inboxes. */
-	Collection<Group> getGroups() throws DbException;
-
 	/**
-	 * Returns the ID of the inbox group for the given contact, or null if no
-	 * inbox group has been set.
+	 * Returns all groups belonging to the given client to which the user
+	 * subscribes.
 	 */
-	GroupId getInboxGroupId(ContactId c) throws DbException;
-
-	/**
-	 * Returns the headers of all messages in the inbox group for the given
-	 * contact, or null if no inbox group has been set.
-	 */
-	Collection<MessageHeader> getInboxMessageHeaders(ContactId c)
-			throws DbException;
+	Collection<Group> getGroups(ClientId c) throws DbException;
 
 	/** Returns the local pseudonym with the given ID. */
 	LocalAuthor getLocalAuthor(AuthorId a) throws DbException;
@@ -176,15 +174,35 @@ public interface DatabaseComponent {
 	/** Returns the local transport properties for the given transport. */
 	TransportProperties getLocalProperties(TransportId t) throws DbException;
 
-	/** Returns the body of the message with the given ID. */
-	byte[] getMessageBody(MessageId m) throws DbException;
+	/**
+	 * Returns the IDs of any messages that need to be validated by the given
+	 * client.
+	 */
+	Collection<MessageId> getMessagesToValidate(ClientId c) throws DbException;
+
+	/** Returns the message with the given ID, in serialised form. */
+	byte[] getRawMessage(MessageId m) throws DbException;
+
+	/** Returns the metadata for all messages in the given group. */
+	Map<MessageId, Metadata> getMessageMetadata(GroupId g)
+			throws DbException;
+
+	/** Returns the metadata for the given message. */
+	Metadata getMessageMetadata(MessageId m) throws DbException;
 
-	/** Returns the headers of all messages in the given group. */
-	Collection<MessageHeader> getMessageHeaders(GroupId g)
+	/**
+	 * Returns the status of all messages in the given group with respect to
+	 * the given contact.
+	 */
+	Collection<MessageStatus> getMessageStatus(ContactId c, GroupId g)
 			throws DbException;
 
-	/** Returns true if the given message is marked as read. */
-	boolean getReadFlag(MessageId m) throws DbException;
+	/**
+	 * Returns the status of the given message with respect to the given
+	 * contact.
+	 */
+	MessageStatus getMessageStatus(ContactId c, MessageId m)
+			throws DbException;
 
 	/** Returns all remote transport properties for the given transport. */
 	Map<ContactId, TransportProperties> getRemoteProperties(TransportId t)
@@ -203,9 +221,6 @@ public interface DatabaseComponent {
 	/** Returns the maximum latencies in milliseconds of all transports. */
 	Map<TransportId, Integer> getTransportLatencies() throws DbException;
 
-	/** Returns the number of unread messages in each subscribed group. */
-	Map<GroupId, Integer> getUnreadMessageCounts() throws DbException;
-
 	/** Returns the IDs of all contacts to which the given group is visible. */
 	Collection<ContactId> getVisibility(GroupId g) throws DbException;
 
@@ -223,6 +238,12 @@ public interface DatabaseComponent {
 	void mergeLocalProperties(TransportId t, TransportProperties p)
 			throws DbException;
 
+	/**
+	 * Merges the given metadata with the existing metadata for the given
+	 * message.
+	 */
+	void mergeMessageMetadata(MessageId m, Metadata meta) throws DbException;
+
 	/**
 	 * Merges the given settings with the existing settings in the given
 	 * namespace.
@@ -276,16 +297,9 @@ public interface DatabaseComponent {
 	 */
 	void removeTransport(TransportId t) throws DbException;
 
-	/**
-	 * Makes a group visible to the given contact, adds it to the contact's
-	 * subscriptions, and sets it as the inbox group for the contact.
-	 */
-	void setInboxGroup(ContactId c, Group g) throws DbException;
-
-	/**
-	 * Marks a message as read or unread.
-	 */
-	void setReadFlag(MessageId m, boolean read) throws DbException;
+	/** Marks the given message as valid or invalid. */
+	void setMessageValidity(Message m, ClientId c, boolean valid)
+			throws DbException;
 
 	/**
 	 * Sets the remote transport properties for the given contact, replacing
diff --git a/briar-api/src/org/briarproject/api/db/MessageExistsException.java b/briar-api/src/org/briarproject/api/db/MessageExistsException.java
new file mode 100644
index 0000000000000000000000000000000000000000..7a3a6d2e00f5f42737bf9b78a3e72d553056feeb
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/db/MessageExistsException.java
@@ -0,0 +1,8 @@
+package org.briarproject.api.db;
+
+/**
+ * Thrown when a duplicate message is added to the database. This exception may
+ * occur due to concurrent updates and does not indicate a database error.
+ */
+public class MessageExistsException extends DbException {
+}
diff --git a/briar-api/src/org/briarproject/api/event/MessageAddedEvent.java b/briar-api/src/org/briarproject/api/event/MessageAddedEvent.java
index 5e08c9b20b9f7930383ee829aeca306015405524..dca0212fdc53d2009b2f1e1f09ccb0b82cf4cd46 100644
--- a/briar-api/src/org/briarproject/api/event/MessageAddedEvent.java
+++ b/briar-api/src/org/briarproject/api/event/MessageAddedEvent.java
@@ -22,7 +22,7 @@ public class MessageAddedEvent extends Event {
 
 	/** Returns the ID of the group to which the message belongs. */
 	public GroupId getGroupId() {
-		return message.getGroup().getId();
+		return message.getGroupId();
 	}
 
 	/**
diff --git a/briar-api/src/org/briarproject/api/event/MessageValidatedEvent.java b/briar-api/src/org/briarproject/api/event/MessageValidatedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..c53a8363619ced08be3438d34026b9ba5aa1ef18
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/MessageValidatedEvent.java
@@ -0,0 +1,38 @@
+package org.briarproject.api.event;
+
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Message;
+
+/**
+ * An event that is broadcast when a message has passed or failed validation.
+ */
+public class MessageValidatedEvent extends Event {
+
+	private final Message message;
+	private final ClientId clientId;
+	private final boolean local, valid;
+
+	public MessageValidatedEvent(Message message, ClientId clientId,
+			boolean local, boolean valid) {
+		this.message = message;
+		this.clientId = clientId;
+		this.local = local;
+		this.valid = valid;
+	}
+
+	public Message getMessage() {
+		return message;
+	}
+
+	public ClientId getClientId() {
+		return clientId;
+	}
+
+	public boolean isLocal() {
+		return local;
+	}
+
+	public boolean isValid() {
+		return valid;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/forum/Forum.java b/briar-api/src/org/briarproject/api/forum/Forum.java
index bad5f6ebf004767962a9dd6be700837bbd4fb236..720744a9695976c2f847be5c769ac3499899bbfd 100644
--- a/briar-api/src/org/briarproject/api/forum/Forum.java
+++ b/briar-api/src/org/briarproject/api/forum/Forum.java
@@ -1,10 +1,37 @@
 package org.briarproject.api.forum;
 
+import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 
-public interface Forum {
+public class Forum {
 
-	GroupId getId();
+	private final Group group;
+	private final String name;
 
-	String getName();
+	public Forum(Group group, String name) {
+		this.group = group;
+		this.name = name;
+	}
+
+	public GroupId getId() {
+		return group.getId();
+	}
+
+	public Group getGroup() {
+		return group;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	@Override
+	public int hashCode() {
+		return group.hashCode();
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof Forum && group.equals(((Forum) o).group);
+	}
 }
diff --git a/briar-api/src/org/briarproject/api/forum/ForumConstants.java b/briar-api/src/org/briarproject/api/forum/ForumConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..4c66fd14295a6081a0c192b331aa7da5861a05cb
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/forum/ForumConstants.java
@@ -0,0 +1,19 @@
+package org.briarproject.api.forum;
+
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
+
+public interface ForumConstants {
+
+	/** The maximum length of a forum's name in bytes. */
+	int MAX_FORUM_NAME_LENGTH = MAX_GROUP_DESCRIPTOR_LENGTH - 10;
+
+	/** The length of a forum's random salt in bytes. */
+	int FORUM_SALT_LENGTH = 32;
+
+	/** The maximum length of a forum post's content type in bytes. */
+	int MAX_CONTENT_TYPE_LENGTH = 50;
+
+	/** The maximum length of a forum post's body in bytes. */
+	int MAX_FORUM_POST_BODY_LENGTH = MAX_MESSAGE_BODY_LENGTH - 1024;
+}
diff --git a/briar-api/src/org/briarproject/api/forum/ForumFactory.java b/briar-api/src/org/briarproject/api/forum/ForumFactory.java
deleted file mode 100644
index d069c63d7a554893f5429b26ef7c71ab0f10f215..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/forum/ForumFactory.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.briarproject.api.forum;
-
-public interface ForumFactory {
-
-	/** Creates a forum with the given name and a random salt. */
-	Forum createForum(String name);
-}
diff --git a/briar-api/src/org/briarproject/api/forum/ForumManager.java b/briar-api/src/org/briarproject/api/forum/ForumManager.java
index 41df06559bb2f9c4f03adc9e8e30235cc61c27f5..f256eedd64ef56f923fe12fe71ac9574789a0abb 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumManager.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumManager.java
@@ -3,14 +3,20 @@ package org.briarproject.api.forum;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 
 import java.util.Collection;
 
 public interface ForumManager {
 
+	/** Returns the unique ID of the forum client. */
+	ClientId getClientId();
+
+	/** Creates a forum with the given name. */
+	Forum createForum(String name);
+
 	/**
 	 * Subscribes to a forum, or returns false if the user already has the
 	 * maximum number of forum subscriptions.
@@ -18,7 +24,7 @@ public interface ForumManager {
 	boolean addForum(Forum f) throws DbException;
 
 	/** Stores a local forum post. */
-	void addLocalPost(Message m) throws DbException;
+	void addLocalPost(ForumPost p) throws DbException;
 
 	/** Returns all forums to which the user could subscribe. */
 	Collection<Forum> getAvailableForums() throws DbException;
diff --git a/briar-api/src/org/briarproject/api/forum/ForumPost.java b/briar-api/src/org/briarproject/api/forum/ForumPost.java
new file mode 100644
index 0000000000000000000000000000000000000000..404d6c520e8cb33661298fa48a7acc123c278e31
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/forum/ForumPost.java
@@ -0,0 +1,37 @@
+package org.briarproject.api.forum;
+
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+
+public class ForumPost {
+
+	private final Message message;
+	private final MessageId parent;
+	private final Author author;
+	private final String contentType;
+
+	public ForumPost(Message message, MessageId parent, Author author,
+			String contentType) {
+		this.message = message;
+		this.parent = parent;
+		this.author = author;
+		this.contentType = contentType;
+	}
+
+	public Message getMessage() {
+		return message;
+	}
+
+	public MessageId getParent() {
+		return parent;
+	}
+
+	public Author getAuthor() {
+		return author;
+	}
+
+	public String getContentType() {
+		return contentType;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java b/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java
index 185eb16b003880caf569d2043eb66aece0973822..3bcfd7b7d0db546d0358b6660e58cf80fdb80138 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java
@@ -2,7 +2,7 @@ package org.briarproject.api.forum;
 
 import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 
 import java.io.IOException;
@@ -10,12 +10,12 @@ import java.security.GeneralSecurityException;
 
 public interface ForumPostFactory {
 
-	Message createAnonymousPost(MessageId parent, Forum forum,
-			String contentType, long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException;
+	ForumPost createAnonymousPost(GroupId groupId, long timestamp,
+			MessageId parent, String contentType, byte[] body)
+			throws IOException, GeneralSecurityException;
 
-	Message createPseudonymousPost(MessageId parent, Forum forum,
-			Author author, PrivateKey privateKey, String contentType,
-			long timestamp, byte[] body) throws IOException,
+	ForumPost createPseudonymousPost(GroupId groupId, long timestamp,
+			MessageId parent, Author author, String contentType, byte[] body,
+			PrivateKey privateKey) throws IOException,
 			GeneralSecurityException;
 }
diff --git a/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java b/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java
index ba861fca6f16c9c3619615dbac0f0e0ad588cd4e..15030223eeac2888443e2daee453be1bf8bc1150 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java
@@ -3,17 +3,46 @@ package org.briarproject.api.forum;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.sync.MessageId;
 
-public interface ForumPostHeader {
-
-	MessageId getId();
-
-	Author getAuthor();
-
-	Author.Status getAuthorStatus();
-
-	String getContentType();
-
-	long getTimestamp();
-
-	boolean isRead();
+public class ForumPostHeader {
+
+	private final MessageId id;
+	private final long timestamp;
+	private final Author author;
+	private final Author.Status authorStatus;
+	private final String contentType;
+	private final boolean read;
+
+	public ForumPostHeader(MessageId id, long timestamp, Author author,
+			Author.Status authorStatus, String contentType, boolean read) {
+		this.id = id;
+		this.timestamp = timestamp;
+		this.author = author;
+		this.authorStatus = authorStatus;
+		this.contentType = contentType;
+		this.read = read;
+	}
+
+	public MessageId getId() {
+		return id;
+	}
+
+	public Author getAuthor() {
+		return author;
+	}
+
+	public Author.Status getAuthorStatus() {
+		return authorStatus;
+	}
+
+	public String getContentType() {
+		return contentType;
+	}
+
+	public long getTimestamp() {
+		return timestamp;
+	}
+
+	public boolean isRead() {
+		return read;
+	}
 }
diff --git a/briar-api/src/org/briarproject/api/identity/AuthorId.java b/briar-api/src/org/briarproject/api/identity/AuthorId.java
index b0fd7d10dd496637a15c1553da97699879829192..4e346239c7e09325c70af864cf09773f76752107 100644
--- a/briar-api/src/org/briarproject/api/identity/AuthorId.java
+++ b/briar-api/src/org/briarproject/api/identity/AuthorId.java
@@ -2,22 +2,25 @@ package org.briarproject.api.identity;
 
 import org.briarproject.api.UniqueId;
 
+import java.nio.charset.Charset;
 import java.util.Arrays;
 
 /**
  * Type-safe wrapper for a byte array that uniquely identifies an
- * {@link Author}.
+ * {@link org.briarproject.api.identity.Author Author}.
  */
 public class AuthorId extends UniqueId {
 
+	/** Label for hashing authors to calculate their identities. */
+	public static final byte[] LABEL =
+			"AUTHOR_ID".getBytes(Charset.forName("US-ASCII"));
+
 	public AuthorId(byte[] id) {
 		super(id);
 	}
 
 	@Override
 	public boolean equals(Object o) {
-		if (o instanceof AuthorId)
-			return Arrays.equals(id, ((AuthorId) o).id);
-		return false;
+		return o instanceof AuthorId && Arrays.equals(id, ((AuthorId) o).id);
 	}
 }
diff --git a/briar-api/src/org/briarproject/api/messaging/MessagingConstants.java b/briar-api/src/org/briarproject/api/messaging/MessagingConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..98750b5518c58a0b487b62d2a8d416dd7903588c
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/messaging/MessagingConstants.java
@@ -0,0 +1,12 @@
+package org.briarproject.api.messaging;
+
+import static org.briarproject.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
+
+public interface MessagingConstants {
+
+	/** The maximum length of a private message's content type in bytes. */
+	int MAX_CONTENT_TYPE_LENGTH = 50;
+
+	/** The maximum length of a private message's body in bytes. */
+	int MAX_PRIVATE_MESSAGE_BODY_LENGTH = MAX_MESSAGE_BODY_LENGTH - 1024;
+}
diff --git a/briar-api/src/org/briarproject/api/messaging/MessagingManager.java b/briar-api/src/org/briarproject/api/messaging/MessagingManager.java
index ade6ab4c745d72044feb0f4592efd621d4c2b9ba..7ce3241f05c2c6bd379fa1487c035f5e7daad534 100644
--- a/briar-api/src/org/briarproject/api/messaging/MessagingManager.java
+++ b/briar-api/src/org/briarproject/api/messaging/MessagingManager.java
@@ -1,37 +1,35 @@
 package org.briarproject.api.messaging;
 
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.crypto.SecretKey;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 
 import java.util.Collection;
 
 public interface MessagingManager {
 
+	/** Returns the unique ID of the messaging client. */
+	ClientId getClientId();
+
 	/**
 	 * Informs the messaging manager that a new contact has been added.
 	 * Creates a private conversation with the contact.
 	 */
-	void addContact(ContactId c, SecretKey master) throws DbException;
+	void addContact(ContactId c) throws DbException;
 
 	/** Stores a local private message. */
-	void addLocalMessage(Message m) throws DbException;
+	void addLocalMessage(PrivateMessage m) throws DbException;
 
-	/** Returns the private conversation with the given ID. */
-	PrivateConversation getConversation(GroupId g) throws DbException;
+	/** Returns the ID of the contact with the given private conversation. */
+	ContactId getContactId(GroupId g) throws DbException;
 
-	/**
-	 * Returns the ID of the private conversation with the given contact, or
-	 * null if no private conversation ID has been set.
-	 */
+	/** Returns the ID of the private conversation with the given contact. */
 	GroupId getConversationId(ContactId c) throws DbException;
 
 	/**
-	 * Returns the headers of all messages in the private conversation with the
-	 * given contact, or null if no private conversation ID has been set.
+	 * Returns the headers of all messages in the given private conversation.
 	 */
 	Collection<PrivateMessageHeader> getMessageHeaders(ContactId c)
 			throws DbException;
@@ -39,13 +37,6 @@ public interface MessagingManager {
 	/** Returns the body of the private message with the given ID. */
 	byte[] getMessageBody(MessageId m) throws DbException;
 
-	/**
-	 * Makes a private conversation visible to the given contact, adds it to
-	 * the contact's subscriptions, and sets it as the private conversation for
-	 * the contact.
-	 */
-	void setConversation(ContactId c, PrivateConversation p) throws DbException;
-
 	/** Marks a private message as read or unread. */
 	void setReadFlag(MessageId m, boolean read) throws DbException;
 }
diff --git a/briar-api/src/org/briarproject/api/messaging/PrivateConversation.java b/briar-api/src/org/briarproject/api/messaging/PrivateConversation.java
index a94d183550eda4f4b9f00a9e2b327aceda6ea653..0f03ed910e403872bc66603f4a22d617082f112f 100644
--- a/briar-api/src/org/briarproject/api/messaging/PrivateConversation.java
+++ b/briar-api/src/org/briarproject/api/messaging/PrivateConversation.java
@@ -1,8 +1,33 @@
 package org.briarproject.api.messaging;
 
+import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 
-public interface PrivateConversation {
+// TODO: Remove if no longer needed
+public class PrivateConversation {
 
-	GroupId getId();
+	private final Group group;
+
+	public PrivateConversation(Group group) {
+		this.group = group;
+	}
+
+	public GroupId getId() {
+		return group.getId();
+	}
+
+	public Group getGroup() {
+		return group;
+	}
+
+	@Override
+	public int hashCode() {
+		return group.hashCode();
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof PrivateConversation
+				&& group.equals(((PrivateConversation) o).group);
+	}
 }
diff --git a/briar-api/src/org/briarproject/api/messaging/PrivateMessage.java b/briar-api/src/org/briarproject/api/messaging/PrivateMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..7a51b374d81da43bec1da19063b51b69caddc8e1
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/messaging/PrivateMessage.java
@@ -0,0 +1,30 @@
+package org.briarproject.api.messaging;
+
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+
+public class PrivateMessage {
+
+	private final Message message;
+	private final MessageId parent;
+	private final String contentType;
+
+	public PrivateMessage(Message message, MessageId parent,
+			String contentType) {
+		this.message = message;
+		this.parent = parent;
+		this.contentType = contentType;
+	}
+
+	public Message getMessage() {
+		return message;
+	}
+
+	public MessageId getParent() {
+		return parent;
+	}
+
+	public String getContentType() {
+		return contentType;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/messaging/PrivateMessageFactory.java b/briar-api/src/org/briarproject/api/messaging/PrivateMessageFactory.java
index 09c5102c800545cb1aa82526b2139fe60e8c63e3..72fded05b20215a0d16e617c2cb7cda26c1b9113 100644
--- a/briar-api/src/org/briarproject/api/messaging/PrivateMessageFactory.java
+++ b/briar-api/src/org/briarproject/api/messaging/PrivateMessageFactory.java
@@ -1,6 +1,6 @@
 package org.briarproject.api.messaging;
 
-import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 
 import java.io.IOException;
@@ -8,8 +8,7 @@ import java.security.GeneralSecurityException;
 
 public interface PrivateMessageFactory {
 
-	Message createPrivateMessage(MessageId parent,
-			PrivateConversation conversation, String contentType,
-			long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException;
+	PrivateMessage createPrivateMessage(GroupId groupId, long timestamp,
+			MessageId parent, String contentType, byte[] body)
+			throws IOException, GeneralSecurityException;
 }
diff --git a/briar-api/src/org/briarproject/api/messaging/PrivateMessageHeader.java b/briar-api/src/org/briarproject/api/messaging/PrivateMessageHeader.java
index 1f81e21d13ef7cc40966d29293ef05614a364cd5..9db8854a1d819a04df07b8446fd30adaa8ea4ff4 100644
--- a/briar-api/src/org/briarproject/api/messaging/PrivateMessageHeader.java
+++ b/briar-api/src/org/briarproject/api/messaging/PrivateMessageHeader.java
@@ -1,23 +1,51 @@
 package org.briarproject.api.messaging;
 
-import org.briarproject.api.identity.Author;
 import org.briarproject.api.sync.MessageId;
 
-public interface PrivateMessageHeader {
-
-	enum Status { STORED, SENT, DELIVERED }
-
-	MessageId getId();
-
-	Author getAuthor();
-
-	String getContentType();
-
-	long getTimestamp();
-
-	boolean isLocal();
-
-	boolean isRead();
-
-	Status getStatus();
+public class PrivateMessageHeader {
+
+	private final MessageId id;
+	private final long timestamp;
+	private final String contentType;
+	private final boolean local, read, sent, seen;
+
+	public PrivateMessageHeader(MessageId id, long timestamp,
+			String contentType, boolean local, boolean read, boolean sent,
+			boolean seen) {
+		this.id = id;
+		this.timestamp = timestamp;
+		this.contentType = contentType;
+		this.local = local;
+		this.read = read;
+		this.sent = sent;
+		this.seen = seen;
+	}
+
+	public MessageId getId() {
+		return id;
+	}
+
+	public String getContentType() {
+		return contentType;
+	}
+
+	public long getTimestamp() {
+		return timestamp;
+	}
+
+	public boolean isLocal() {
+		return local;
+	}
+
+	public boolean isRead() {
+		return read;
+	}
+
+	public boolean isSent() {
+		return sent;
+	}
+
+	public boolean isSeen() {
+		return seen;
+	}
 }
diff --git a/briar-api/src/org/briarproject/api/sync/ClientId.java b/briar-api/src/org/briarproject/api/sync/ClientId.java
new file mode 100644
index 0000000000000000000000000000000000000000..d99f5c18dffa79668ac1a24c8dae48d1494b35c6
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/sync/ClientId.java
@@ -0,0 +1,20 @@
+package org.briarproject.api.sync;
+
+import org.briarproject.api.UniqueId;
+
+import java.util.Arrays;
+
+/**
+ * Type-safe wrapper for a byte array that uniquely identifies a sync client.
+ */
+public class ClientId extends UniqueId {
+
+	public ClientId(byte[] id) {
+		super(id);
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof ClientId && Arrays.equals(id, ((ClientId) o).id);
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/sync/Group.java b/briar-api/src/org/briarproject/api/sync/Group.java
index c10accdb8491fcc77ee186f2a991a76a07232478..51818a3822ba93f08455d7f62ada6e6b9f880b4e 100644
--- a/briar-api/src/org/briarproject/api/sync/Group.java
+++ b/briar-api/src/org/briarproject/api/sync/Group.java
@@ -1,28 +1,20 @@
 package org.briarproject.api.sync;
 
-import java.io.UnsupportedEncodingException;
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
 
 /** A group to which users may subscribe. */
 public class Group {
 
 	private final GroupId id;
-	private final String name;
-	private final byte[] salt;
-
-	public Group(GroupId id, String name, byte[] salt) {
-		int length;
-		try {
-			length = name.getBytes("UTF-8").length;
-		} catch (UnsupportedEncodingException e) {
-			throw new RuntimeException(e);
-		}
-		if (length == 0 || length > MessagingConstants.MAX_GROUP_NAME_LENGTH)
-			throw new IllegalArgumentException();
-		if (salt.length != MessagingConstants.GROUP_SALT_LENGTH)
+	private final ClientId clientId;
+	private final byte[] descriptor;
+
+	public Group(GroupId id, ClientId clientId, byte[] descriptor) {
+		if (descriptor.length > MAX_GROUP_DESCRIPTOR_LENGTH)
 			throw new IllegalArgumentException();
 		this.id = id;
-		this.name = name;
-		this.salt = salt;
+		this.clientId = clientId;
+		this.descriptor = descriptor;
 	}
 
 	/** Returns the group's unique identifier. */
@@ -30,17 +22,14 @@ public class Group {
 		return id;
 	}
 
-	/** Returns the group's name. */
-	public String getName() {
-		return name;
+	/** Returns the ID of the client to which the group belongs. */
+	public ClientId getClientId() {
+		return clientId;
 	}
 
-	/**
-	 * Returns the salt used to distinguish the group from other groups with
-	 * the same name.
-	 */
-	public byte[] getSalt() {
-		return salt;
+	/** Returns the group's descriptor. */
+	public byte[] getDescriptor() {
+		return descriptor;
 	}
 
 	@Override
diff --git a/briar-api/src/org/briarproject/api/sync/GroupFactory.java b/briar-api/src/org/briarproject/api/sync/GroupFactory.java
index 2850bec19cce6ec438ad3aa6b50c308b59be1272..02187cb8f58155356797569c4539bf57708ce099 100644
--- a/briar-api/src/org/briarproject/api/sync/GroupFactory.java
+++ b/briar-api/src/org/briarproject/api/sync/GroupFactory.java
@@ -2,9 +2,6 @@ package org.briarproject.api.sync;
 
 public interface GroupFactory {
 
-	/** Creates a group with the given name and a random salt. */
-	Group createGroup(String name);
-
-	/** Creates a group with the given name and salt. */
-	Group createGroup(String name, byte[] salt);
+	/** Creates a group with the given client ID and descriptor. */
+	Group createGroup(ClientId c, byte[] descriptor);
 }
diff --git a/briar-api/src/org/briarproject/api/sync/GroupId.java b/briar-api/src/org/briarproject/api/sync/GroupId.java
index dc5a4b9239cfee5c344196a04e8a335df9186bbf..6a86445578f0c80f740114b71ba30161a3766205 100644
--- a/briar-api/src/org/briarproject/api/sync/GroupId.java
+++ b/briar-api/src/org/briarproject/api/sync/GroupId.java
@@ -2,6 +2,7 @@ package org.briarproject.api.sync;
 
 import org.briarproject.api.UniqueId;
 
+import java.nio.charset.Charset;
 import java.util.Arrays;
 
 /**
@@ -9,14 +10,16 @@ import java.util.Arrays;
  */
 public class GroupId extends UniqueId {
 
+	/** Label for hashing groups to calculate their identifiers. */
+	public static final byte[] LABEL =
+			"GROUP_ID".getBytes(Charset.forName("US-ASCII"));
+
 	public GroupId(byte[] id) {
 		super(id);
 	}
 
 	@Override
 	public boolean equals(Object o) {
-		if (o instanceof GroupId)
-			return Arrays.equals(id, ((GroupId) o).id);
-		return false;
+		return o instanceof GroupId && Arrays.equals(id, ((GroupId) o).id);
 	}
 }
diff --git a/briar-api/src/org/briarproject/api/sync/Message.java b/briar-api/src/org/briarproject/api/sync/Message.java
index 14c16d371dd9839e1d8fce457a8191f3e6209a3e..049807ac4b25ead9554860ce1229635bd6d23fc3 100644
--- a/briar-api/src/org/briarproject/api/sync/Message.java
+++ b/briar-api/src/org/briarproject/api/sync/Message.java
@@ -1,42 +1,58 @@
 package org.briarproject.api.sync;
 
-import org.briarproject.api.identity.Author;
-
-public interface Message {
+import static org.briarproject.api.sync.SyncConstants.MAX_MESSAGE_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+
+public class Message {
+
+	private final MessageId id;
+	private final GroupId groupId;
+	private final long timestamp;
+	private final byte[] raw;
+
+	public Message(MessageId id, GroupId groupId, long timestamp, byte[] raw) {
+		if (raw.length <= MESSAGE_HEADER_LENGTH)
+			throw new IllegalArgumentException();
+		if (raw.length > MAX_MESSAGE_LENGTH)
+			throw new IllegalArgumentException();
+		this.id = id;
+		this.groupId = groupId;
+		this.timestamp = timestamp;
+		this.raw = raw;
+	}
 
 	/** Returns the message's unique identifier. */
-	MessageId getId();
-
-	/**
-	 * Returns the identifier of the message's parent, or null if this is the
-	 * first message in a thread.
-	 */
-	MessageId getParent();
-
-	/**
-	 * Returns the {@link Group} to which the message belongs, or null if this
-	 * is a private message.
-	 */
-	Group getGroup();
-
-	/**
-	 * Returns the message's {@link Author Author}, or null
-	 * if this is an anonymous message.
-	 */
-	Author getAuthor();
+	public MessageId getId() {
+		return id;
+	}
 
-	/** Returns the message's content type. */
-	String getContentType();
+	/** Returns the ID of the {@link Group} to which the message belongs. */
+	public GroupId getGroupId() {
+		return groupId;
+	}
 
 	/** Returns the message's timestamp in milliseconds since the Unix epoch. */
-	long getTimestamp();
-
-	/** Returns the serialised message. */
-	byte[] getSerialised();
-
-	/** Returns the offset of the message body within the serialised message. */
-	int getBodyStart();
-
-	/** Returns the length of the message body in bytes. */
-	int getBodyLength();
+	public long getTimestamp() {
+		return timestamp;
+	}
+
+	/** Returns the length of the raw message in bytes. */
+	public int getLength() {
+		return raw.length;
+	}
+
+	/** Returns the raw message. */
+	public byte[] getRaw() {
+		return raw;
+	}
+
+	@Override
+	public int hashCode() {
+		return id.hashCode();
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof Message && id.equals(((Message) o).getId());
+	}
 }
\ No newline at end of file
diff --git a/briar-api/src/org/briarproject/api/sync/MessageFactory.java b/briar-api/src/org/briarproject/api/sync/MessageFactory.java
index b54070d78cd8eac8173d8f0dfeda5c056fd6c442..6e65fc44cd802225abe22d44a97e037d35410c10 100644
--- a/briar-api/src/org/briarproject/api/sync/MessageFactory.java
+++ b/briar-api/src/org/briarproject/api/sync/MessageFactory.java
@@ -1,19 +1,9 @@
 package org.briarproject.api.sync;
 
-import org.briarproject.api.crypto.PrivateKey;
-import org.briarproject.api.identity.Author;
-
 import java.io.IOException;
-import java.security.GeneralSecurityException;
 
 public interface MessageFactory {
 
-	Message createAnonymousMessage(MessageId parent, Group group,
-			String contentType, long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException;
-
-	Message createPseudonymousMessage(MessageId parent, Group group,
-			Author author, PrivateKey privateKey, String contentType,
-			long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException;
+	Message createMessage(GroupId groupId, long timestamp, byte[] body)
+			throws IOException;
 }
diff --git a/briar-api/src/org/briarproject/api/sync/MessageHeader.java b/briar-api/src/org/briarproject/api/sync/MessageHeader.java
deleted file mode 100644
index 701fbf5474154c1099b0b477adc1b6d3c9012990..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/sync/MessageHeader.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package org.briarproject.api.sync;
-
-import org.briarproject.api.identity.Author;
-
-public class MessageHeader {
-
-	public enum State { STORED, SENT, DELIVERED }
-
-	private final MessageId id, parent;
-	private final GroupId groupId;
-	private final Author author;
-	private final Author.Status authorStatus;
-	private final String contentType;
-	private final long timestamp;
-	private final boolean local, read;
-	private final State status;
-
-	public MessageHeader(MessageId id, MessageId parent, GroupId groupId,
-			Author author, Author.Status authorStatus, String contentType,
-			long timestamp, boolean local, boolean read, State status) {
-		this.id = id;
-		this.parent = parent;
-		this.groupId = groupId;
-		this.author = author;
-		this.authorStatus = authorStatus;
-		this.contentType = contentType;
-		this.timestamp = timestamp;
-		this.local = local;
-		this.read = read;
-		this.status = status;
-	}
-
-	/** Returns the message's unique identifier. */
-	public MessageId getId() {
-		return id;
-	}
-
-	/**
-	 * Returns the message's parent, or null if this is the first message in a
-	 * thread.
-	 */
-	public MessageId getParent() {
-		return parent;
-	}
-
-	/**
-	 * Returns the unique identifier of the group to which the message belongs.
-	 */
-	public GroupId getGroupId() {
-		return groupId;
-	}
-
-	/**
-	 * Returns the message's author, or null if this is an  anonymous message.
-	 */
-	public Author getAuthor() {
-		return author;
-	}
-
-	/**  Returns the status of the message's author. */
-	public Author.Status getAuthorStatus() {
-		return authorStatus;
-	}
-
-	/** Returns the message's content type. */
-	public String getContentType() {
-		return contentType;
-	}
-
-	/** Returns the timestamp created by the message's author. */
-	public long getTimestamp() {
-		return timestamp;
-	}
-
-	/** Returns true if the message was locally generated. */
-	public boolean isLocal() {
-		return local;
-	}
-
-	/** Returns true if the message has been read. */
-	public boolean isRead() {
-		return read;
-	}
-
-	/**
-	 * Returns message status. (This only applies to locally generated private
-	 * messages.)
-	 */
-	public State getStatus() {
-		return status;
-	}
-}
diff --git a/briar-api/src/org/briarproject/api/sync/MessageId.java b/briar-api/src/org/briarproject/api/sync/MessageId.java
index 472caec3fbe343c70402d75257592d8b191d809e..f437c511b4b09d56874e9a9ead439da00dda431d 100644
--- a/briar-api/src/org/briarproject/api/sync/MessageId.java
+++ b/briar-api/src/org/briarproject/api/sync/MessageId.java
@@ -2,6 +2,7 @@ package org.briarproject.api.sync;
 
 import org.briarproject.api.UniqueId;
 
+import java.nio.charset.Charset;
 import java.util.Arrays;
 
 /**
@@ -10,14 +11,16 @@ import java.util.Arrays;
  */
 public class MessageId extends UniqueId {
 
+	/** Label for hashing messages to calculate their identifiers. */
+	public static final byte[] LABEL =
+			"MESSAGE_ID".getBytes(Charset.forName("US-ASCII"));
+
 	public MessageId(byte[] id) {
 		super(id);
 	}
 
 	@Override
 	public boolean equals(Object o) {
-		if (o instanceof MessageId)
-			return Arrays.equals(id, ((MessageId) o).id);
-		return false;
+		return o instanceof MessageId && Arrays.equals(id, ((MessageId) o).id);
 	}
 }
diff --git a/briar-api/src/org/briarproject/api/sync/MessageStatus.java b/briar-api/src/org/briarproject/api/sync/MessageStatus.java
new file mode 100644
index 0000000000000000000000000000000000000000..ec1df8cc4879751e1445e27c3da0bd82d05815e7
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/sync/MessageStatus.java
@@ -0,0 +1,38 @@
+package org.briarproject.api.sync;
+
+import org.briarproject.api.contact.ContactId;
+
+public class MessageStatus {
+
+	private final MessageId messageId;
+	private final ContactId contactId;
+	private final boolean sent, seen;
+
+	public MessageStatus(MessageId messageId, ContactId contactId,
+			boolean sent, boolean seen) {
+		this.messageId = messageId;
+		this.contactId = contactId;
+		this.sent = sent;
+		this.seen = seen;
+	}
+
+	/** Returns the ID of the message. */
+	public MessageId getMessageId() {
+		return messageId;
+	}
+
+	/** Returns the ID of the contact. */
+	public ContactId getContactId() {
+		return contactId;
+	}
+
+	/** Returns true if the message has been sent to the contact. */
+	public boolean isSent() {
+		return sent;
+	}
+
+	/** Returns true if the message has been seen by the contact. */
+	public boolean isSeen() {
+		return seen;
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/sync/MessageValidator.java b/briar-api/src/org/briarproject/api/sync/MessageValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..d318421a472c16b5a78f597028c8786901477fd7
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/sync/MessageValidator.java
@@ -0,0 +1,13 @@
+package org.briarproject.api.sync;
+
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.lifecycle.Service;
+
+public interface MessageValidator extends Service {
+
+	/**
+	 * Validates the given message and returns its metadata if the message
+	 * is valid, or null if the message is invalid.
+	 */
+	Metadata validateMessage(Message m);
+}
diff --git a/briar-api/src/org/briarproject/api/sync/MessageVerifier.java b/briar-api/src/org/briarproject/api/sync/MessageVerifier.java
deleted file mode 100644
index 87ee02be696103372689cc05758aa0bcaeece0ed..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/sync/MessageVerifier.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.briarproject.api.sync;
-
-import java.security.GeneralSecurityException;
-
-/** Verifies the signatures on an {@link UnverifiedMessage}. */
-public interface MessageVerifier {
-
-	Message verifyMessage(UnverifiedMessage m) throws GeneralSecurityException;
-}
diff --git a/briar-api/src/org/briarproject/api/sync/MessagingConstants.java b/briar-api/src/org/briarproject/api/sync/MessagingConstants.java
deleted file mode 100644
index 5353f442f7365583b2b2ede9004a3cbaf92e2e47..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/sync/MessagingConstants.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package org.briarproject.api.sync;
-
-
-public interface MessagingConstants {
-
-	/** The current version of the messaging protocol. */
-	byte PROTOCOL_VERSION = 0;
-
-	/** The length of the packet header in bytes. */
-	int HEADER_LENGTH = 4;
-
-	/** The maximum length of the packet payload in bytes. */
-	int MAX_PAYLOAD_LENGTH = 32 * 1024; // 32 KiB
-
-	/** The maximum number of public groups a user may subscribe to. */
-	int MAX_SUBSCRIPTIONS = 300;
-
-	/** The maximum length of a group's name in UTF-8 bytes. */
-	int MAX_GROUP_NAME_LENGTH = 50;
-
-	/** The length of a group's random salt in bytes. */
-	int GROUP_SALT_LENGTH = 32;
-
-	/**
-	 * The maximum length of a message body in bytes. To allow for future
-	 * changes in the protocol, this is smaller than the maximum payload length
-	 * even when all the message's other fields have their maximum lengths.
-	 */
-	int MAX_BODY_LENGTH = MAX_PAYLOAD_LENGTH - 1024;
-
-	/** The maximum length of a message's content type in UTF-8 bytes. */
-	int MAX_CONTENT_TYPE_LENGTH = 50;
-
-	/** The maximum length of a message's subject line in UTF-8 bytes. */
-	int MAX_SUBJECT_LENGTH = 100;
-
-	/** The length of a message's random salt in bytes. */
-	int MESSAGE_SALT_LENGTH = 32;
-
-	/**
-	 * When calculating the retention time of the database, the timestamp of
-	 * the oldest message in the database is rounded down to a multiple of
-	 * this value to avoid revealing the presence of any particular message.
-	 */
-	int RETENTION_GRANULARITY = 60 * 1000; // 1 minute
-}
diff --git a/briar-api/src/org/briarproject/api/sync/PacketReader.java b/briar-api/src/org/briarproject/api/sync/PacketReader.java
index 8ee829e9f0b75913527cb549c225ffe11fdb126e..a942adc7cacd88c6c3c4cc66da03dce2e123d745 100644
--- a/briar-api/src/org/briarproject/api/sync/PacketReader.java
+++ b/briar-api/src/org/briarproject/api/sync/PacketReader.java
@@ -10,7 +10,7 @@ public interface PacketReader {
 	Ack readAck() throws IOException;
 
 	boolean hasMessage() throws IOException;
-	UnverifiedMessage readMessage() throws IOException;
+	Message readMessage() throws IOException;
 
 	boolean hasOffer() throws IOException;
 	Offer readOffer() throws IOException;
diff --git a/briar-api/src/org/briarproject/api/sync/PacketTypes.java b/briar-api/src/org/briarproject/api/sync/PacketTypes.java
index 873b89b3a81a93d90501b550e2834d0fbe025224..2bbeeae06b008bc38e7df96b8731826c05ae8897 100644
--- a/briar-api/src/org/briarproject/api/sync/PacketTypes.java
+++ b/briar-api/src/org/briarproject/api/sync/PacketTypes.java
@@ -1,6 +1,6 @@
 package org.briarproject.api.sync;
 
-/** Packet types for the messaging protocol. */
+/** Packet types for the sync protocol. */
 public interface PacketTypes {
 
 	byte ACK = 0;
diff --git a/briar-api/src/org/briarproject/api/sync/SyncConstants.java b/briar-api/src/org/briarproject/api/sync/SyncConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..3cb297a4bd30e6ba51521f4cc1006768e06d06d4
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/sync/SyncConstants.java
@@ -0,0 +1,30 @@
+package org.briarproject.api.sync;
+
+import org.briarproject.api.UniqueId;
+
+public interface SyncConstants {
+
+	/** The current version of the sync protocol. */
+	byte PROTOCOL_VERSION = 0;
+
+	/** The length of the packet header in bytes. */
+	int PACKET_HEADER_LENGTH = 4;
+
+	/** The maximum length of the packet payload in bytes. */
+	int MAX_PACKET_PAYLOAD_LENGTH = 32 * 1024; // 32 KiB
+
+	/** The maximum number of groups a user may subscribe to. */
+	int MAX_SUBSCRIPTIONS = 200;
+
+	/** The maximum length of a group descriptor in bytes. */
+	int MAX_GROUP_DESCRIPTOR_LENGTH = 100;
+
+	/** The maximum length of a message in bytes. */
+	int MAX_MESSAGE_LENGTH = MAX_PACKET_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH;
+
+	/** The length of the message header in bytes. */
+	int MESSAGE_HEADER_LENGTH = UniqueId.LENGTH + 8;
+
+	/** The maximum length of a message body in bytes. */
+	int MAX_MESSAGE_BODY_LENGTH = MAX_MESSAGE_LENGTH - MESSAGE_HEADER_LENGTH;
+}
diff --git a/briar-api/src/org/briarproject/api/sync/MessagingSession.java b/briar-api/src/org/briarproject/api/sync/SyncSession.java
similarity index 91%
rename from briar-api/src/org/briarproject/api/sync/MessagingSession.java
rename to briar-api/src/org/briarproject/api/sync/SyncSession.java
index 17009b964497636d2c45a1e652acbe2f19886fdf..29378814170e4cd588ba308c4157fefa7a021744 100644
--- a/briar-api/src/org/briarproject/api/sync/MessagingSession.java
+++ b/briar-api/src/org/briarproject/api/sync/SyncSession.java
@@ -2,7 +2,7 @@ package org.briarproject.api.sync;
 
 import java.io.IOException;
 
-public interface MessagingSession {
+public interface SyncSession {
 
 	/**
 	 * Runs the session. This method returns when there are no more packets to
diff --git a/briar-api/src/org/briarproject/api/sync/MessagingSessionFactory.java b/briar-api/src/org/briarproject/api/sync/SyncSessionFactory.java
similarity index 53%
rename from briar-api/src/org/briarproject/api/sync/MessagingSessionFactory.java
rename to briar-api/src/org/briarproject/api/sync/SyncSessionFactory.java
index a54695dfe026e6c57b3d249dbcc597cf3530f229..b5db6a3493e9beba4af9ffcb9130325888de1c61 100644
--- a/briar-api/src/org/briarproject/api/sync/MessagingSessionFactory.java
+++ b/briar-api/src/org/briarproject/api/sync/SyncSessionFactory.java
@@ -6,14 +6,14 @@ import org.briarproject.api.contact.ContactId;
 import java.io.InputStream;
 import java.io.OutputStream;
 
-public interface MessagingSessionFactory {
+public interface SyncSessionFactory {
 
-	MessagingSession createIncomingSession(ContactId c, TransportId t,
+	SyncSession createIncomingSession(ContactId c, TransportId t,
 			InputStream in);
 
-	MessagingSession createSimplexOutgoingSession(ContactId c, TransportId t,
+	SyncSession createSimplexOutgoingSession(ContactId c, TransportId t,
 			int maxLatency, OutputStream out);
 
-	MessagingSession createDuplexOutgoingSession(ContactId c, TransportId t,
+	SyncSession createDuplexOutgoingSession(ContactId c, TransportId t,
 			int maxLatency, int maxIdleTime, OutputStream out);
 }
diff --git a/briar-api/src/org/briarproject/api/sync/UnverifiedMessage.java b/briar-api/src/org/briarproject/api/sync/UnverifiedMessage.java
deleted file mode 100644
index 523cc1e305b1d3b145626b8248670aac61b557b5..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/sync/UnverifiedMessage.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package org.briarproject.api.sync;
-
-import org.briarproject.api.identity.Author;
-
-/** A {@link Message} that has not yet had its signatures (if any) verified. */
-public class UnverifiedMessage {
-
-	private final MessageId parent;
-	private final Group group;
-	private final Author author;
-	private final String contentType;
-	private final long timestamp;
-	private final byte[] raw, signature;
-	private final int bodyStart, bodyLength, signedLength;
-
-	public UnverifiedMessage(MessageId parent, Group group, Author author,
-			String contentType, long timestamp, byte[] raw, byte[] signature,
-			int bodyStart, int bodyLength, int signedLength) {
-		this.parent = parent;
-		this.group = group;
-		this.author = author;
-		this.contentType = contentType;
-		this.timestamp = timestamp;
-		this.raw = raw;
-		this.signature = signature;
-		this.bodyStart = bodyStart;
-		this.bodyLength = bodyLength;
-		this.signedLength = signedLength;
-	}
-
-	/**
-	 * Returns the identifier of the message's parent, or null if this is the
-	 * first message in a thread.
-	 */
-	public MessageId getParent() {
-		return parent;
-	}
-
-	/**
-	 * Returns the {@link Group} to which the message belongs, or null if this
-	 * is a private message.
-	 */
-	public Group getGroup() {
-		return group;
-	}
-
-	/**
-	 * Returns the message's {@link Author Author}, or null
-	 * if this is an anonymous message.
-	 */
-	public Author getAuthor() {
-		return author;
-	}
-
-	/** Returns the message's content type. */
-	public String getContentType() {
-		return contentType;
-	}
-
-	/** Returns the message's timestamp. */
-	public long getTimestamp() {
-		return timestamp;
-	}
-
-	/** Returns the serialised message. */
-	public byte[] getSerialised() {
-		return raw;
-	}
-
-	/**
-	 * Returns the author's signature, or null if this is an anonymous message.
-	 */
-	public byte[] getSignature() {
-		return signature;
-	}
-
-	/** Returns the offset of the message body within the serialised message. */
-	public int getBodyStart() {
-		return bodyStart;
-	}
-
-	/** Returns the length of the message body in bytes. */
-	public int getBodyLength() {
-		return bodyLength;
-	}
-
-	/**
-	 * Returns the length in bytes of the data covered by the author's
-	 * signature.
-	 */
-	public int getSignedLength() {
-		return signedLength;
-	}
-}
\ No newline at end of file
diff --git a/briar-api/src/org/briarproject/api/sync/ValidationManager.java b/briar-api/src/org/briarproject/api/sync/ValidationManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..729e96ddccd6a47a06a100d4e1f2ac7af01c286b
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/sync/ValidationManager.java
@@ -0,0 +1,13 @@
+package org.briarproject.api.sync;
+
+import org.briarproject.api.lifecycle.Service;
+
+/**
+ * Responsible for managing message validators and passing them messages to
+ * validate.
+ */
+public interface ValidationManager extends Service {
+
+	/** Sets the message validator for the given client. */
+	void setMessageValidator(ClientId c, MessageValidator v);
+}
diff --git a/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java b/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java
index fc9b4bdb8a1df2434b37f4cb64a943705cfc5ba8..4decd40839d9bb1a95551ebc8bcafb90eae33120 100644
--- a/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java
+++ b/briar-core/src/org/briarproject/crypto/CryptoComponentImpl.java
@@ -73,8 +73,6 @@ class CryptoComponentImpl implements CryptoComponent {
 	// KDF labels for signature nonce derivation
 	private static final byte[] A_NONCE = ascii("ALICE_SIGNATURE_NONCE");
 	private static final byte[] B_NONCE = ascii("BOB_SIGNATURE_NONCE");
-	// KDF label for group salt derivation
-	private static final byte[] SALT = ascii("SALT");
 	// KDF labels for tag key derivation
 	private static final byte[] A_TAG = ascii("ALICE_TAG_KEY");
 	private static final byte[] B_TAG = ascii("BOB_TAG_KEY");
@@ -233,10 +231,6 @@ class CryptoComponentImpl implements CryptoComponent {
 		return macKdf(master, alice ? A_NONCE : B_NONCE);
 	}
 
-	public byte[] deriveGroupSalt(SecretKey master) {
-		return macKdf(master, SALT);
-	}
-
 	public TransportKeys deriveTransportKeys(TransportId t,
 			SecretKey master, long rotationPeriod, boolean alice) {
 		// Keys for the previous period are derived from the master secret
@@ -325,6 +319,17 @@ class CryptoComponentImpl implements CryptoComponent {
 		System.arraycopy(mac, 0, tag, 0, TAG_LENGTH);
 	}
 
+	public byte[] hash(byte[]... inputs) {
+		MessageDigest digest = getMessageDigest();
+		byte[] length = new byte[INT_32_BYTES];
+		for (byte[] input : inputs) {
+			ByteUtils.writeUint32(input.length, length, 0);
+			digest.update(length);
+			digest.update(input);
+		}
+		return digest.digest();
+	}
+
 	public byte[] encryptWithPassword(byte[] input, String password) {
 		AuthenticatedCipher cipher = new XSalsa20Poly1305AuthenticatedCipher();
 		int macBytes = cipher.getMacBytes();
diff --git a/briar-core/src/org/briarproject/data/BdfReaderImpl.java b/briar-core/src/org/briarproject/data/BdfReaderImpl.java
index 08ad3a3e342470c5d1550e4e66910d8ea9cbb0ed..71917b3bd912bb5fb3ef47578c2a265b32da132f 100644
--- a/briar-core/src/org/briarproject/data/BdfReaderImpl.java
+++ b/briar-core/src/org/briarproject/data/BdfReaderImpl.java
@@ -2,12 +2,9 @@ package org.briarproject.data;
 
 import org.briarproject.api.FormatException;
 import org.briarproject.api.data.BdfReader;
-import org.briarproject.api.data.Consumer;
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.Collection;
 
 import static org.briarproject.data.Types.DICTIONARY;
 import static org.briarproject.data.Types.END;
@@ -33,7 +30,6 @@ class BdfReaderImpl implements BdfReader {
 	private static final byte[] EMPTY_BUFFER = new byte[] {};
 
 	private final InputStream in;
-	private final Collection<Consumer> consumers = new ArrayList<Consumer>(0);
 
 	private boolean hasLookahead = false, eof = false;
 	private byte next;
@@ -44,8 +40,8 @@ class BdfReaderImpl implements BdfReader {
 	}
 
 	private void readLookahead() throws IOException {
-		assert !eof;
-		assert !hasLookahead;
+		if (eof) throw new IllegalStateException();
+		if (hasLookahead) throw new IllegalStateException();
 		// Read a lookahead byte
 		int i = in.read();
 		if (i == -1) {
@@ -56,27 +52,18 @@ class BdfReaderImpl implements BdfReader {
 		hasLookahead = true;
 	}
 
-	private void consumeLookahead() throws IOException {
-		assert hasLookahead;
-		for (Consumer c : consumers) c.write(next);
-		hasLookahead = false;
-	}
-
-	private void readIntoBuffer(byte[] b, int length, boolean consume)
-			throws IOException {
+	private void readIntoBuffer(byte[] b, int length) throws IOException {
 		int offset = 0;
 		while (offset < length) {
 			int read = in.read(b, offset, length - offset);
 			if (read == -1) throw new FormatException();
 			offset += read;
 		}
-		if (consume) for (Consumer c : consumers) c.write(b, 0, length);
 	}
 
-	private void readIntoBuffer(int length, boolean consume)
-			throws IOException {
+	private void readIntoBuffer(int length) throws IOException {
 		if (buf.length < length) buf = new byte[length];
-		readIntoBuffer(buf, length, consume);
+		readIntoBuffer(buf, length);
 	}
 
 	private void skip(int length) throws IOException {
@@ -108,14 +95,6 @@ class BdfReaderImpl implements BdfReader {
 		in.close();
 	}
 
-	public void addConsumer(Consumer c) {
-		consumers.add(c);
-	}
-
-	public void removeConsumer(Consumer c) {
-		if (!consumers.remove(c)) throw new IllegalArgumentException();
-	}
-
 	public boolean hasNull() throws IOException {
 		if (!hasLookahead) readLookahead();
 		if (eof) return false;
@@ -124,7 +103,7 @@ class BdfReaderImpl implements BdfReader {
 
 	public void readNull() throws IOException {
 		if (!hasNull()) throw new FormatException();
-		consumeLookahead();
+		hasLookahead = false;
 	}
 
 	public void skipNull() throws IOException {
@@ -141,7 +120,7 @@ class BdfReaderImpl implements BdfReader {
 	public boolean readBoolean() throws IOException {
 		if (!hasBoolean()) throw new FormatException();
 		boolean bool = next == TRUE;
-		consumeLookahead();
+		hasLookahead = false;
 		return bool;
 	}
 
@@ -159,32 +138,32 @@ class BdfReaderImpl implements BdfReader {
 
 	public long readInteger() throws IOException {
 		if (!hasInteger()) throw new FormatException();
-		consumeLookahead();
-		if (next == INT_8) return readInt8(true);
-		if (next == INT_16) return readInt16(true);
-		if (next == INT_32) return readInt32(true);
-		return readInt64(true);
+		hasLookahead = false;
+		if (next == INT_8) return readInt8();
+		if (next == INT_16) return readInt16();
+		if (next == INT_32) return readInt32();
+		return readInt64();
 	}
 
-	private int readInt8(boolean consume) throws IOException {
-		readIntoBuffer(1, consume);
+	private int readInt8() throws IOException {
+		readIntoBuffer(1);
 		return buf[0];
 	}
 
-	private short readInt16(boolean consume) throws IOException {
-		readIntoBuffer(2, consume);
+	private short readInt16() throws IOException {
+		readIntoBuffer(2);
 		return (short) (((buf[0] & 0xFF) << 8) + (buf[1] & 0xFF));
 	}
 
-	private int readInt32(boolean consume) throws IOException {
-		readIntoBuffer(4, consume);
+	private int readInt32() throws IOException {
+		readIntoBuffer(4);
 		int value = 0;
 		for (int i = 0; i < 4; i++) value |= (buf[i] & 0xFF) << (24 - i * 8);
 		return value;
 	}
 
-	private long readInt64(boolean consume) throws IOException {
-		readIntoBuffer(8, consume);
+	private long readInt64() throws IOException {
+		readIntoBuffer(8);
 		long value = 0;
 		for (int i = 0; i < 8; i++) value |= (buf[i] & 0xFFL) << (56 - i * 8);
 		return value;
@@ -207,8 +186,8 @@ class BdfReaderImpl implements BdfReader {
 
 	public double readFloat() throws IOException {
 		if (!hasFloat()) throw new FormatException();
-		consumeLookahead();
-		readIntoBuffer(8, true);
+		hasLookahead = false;
+		readIntoBuffer(8);
 		long value = 0;
 		for (int i = 0; i < 8; i++) value |= (buf[i] & 0xFFL) << (56 - i * 8);
 		return Double.longBitsToDouble(value);
@@ -228,24 +207,24 @@ class BdfReaderImpl implements BdfReader {
 
 	public String readString(int maxLength) throws IOException {
 		if (!hasString()) throw new FormatException();
-		consumeLookahead();
-		int length = readStringLength(true);
+		hasLookahead = false;
+		int length = readStringLength();
 		if (length < 0 || length > maxLength) throw new FormatException();
 		if (length == 0) return "";
-		readIntoBuffer(length, true);
+		readIntoBuffer(length);
 		return new String(buf, 0, length, "UTF-8");
 	}
 
-	private int readStringLength(boolean consume) throws IOException {
-		if (next == STRING_8) return readInt8(consume);
-		if (next == STRING_16) return readInt16(consume);
-		if (next == STRING_32) return readInt32(consume);
+	private int readStringLength() throws IOException {
+		if (next == STRING_8) return readInt8();
+		if (next == STRING_16) return readInt16();
+		if (next == STRING_32) return readInt32();
 		throw new FormatException();
 	}
 
 	public void skipString() throws IOException {
 		if (!hasString()) throw new FormatException();
-		int length = readStringLength(false);
+		int length = readStringLength();
 		if (length < 0) throw new FormatException();
 		skip(length);
 		hasLookahead = false;
@@ -259,25 +238,25 @@ class BdfReaderImpl implements BdfReader {
 
 	public byte[] readRaw(int maxLength) throws IOException {
 		if (!hasRaw()) throw new FormatException();
-		consumeLookahead();
-		int length = readRawLength(true);
+		hasLookahead = false;
+		int length = readRawLength();
 		if (length < 0 || length > maxLength) throw new FormatException();
 		if (length == 0) return EMPTY_BUFFER;
 		byte[] b = new byte[length];
-		readIntoBuffer(b, length, true);
+		readIntoBuffer(b, length);
 		return b;
 	}
 
-	private int readRawLength(boolean consume) throws IOException {
-		if (next == RAW_8) return readInt8(consume);
-		if (next == RAW_16) return readInt16(consume);
-		if (next == RAW_32) return readInt32(consume);
+	private int readRawLength() throws IOException {
+		if (next == RAW_8) return readInt8();
+		if (next == RAW_16) return readInt16();
+		if (next == RAW_32) return readInt32();
 		throw new FormatException();
 	}
 
 	public void skipRaw() throws IOException {
 		if (!hasRaw()) throw new FormatException();
-		int length = readRawLength(false);
+		int length = readRawLength();
 		if (length < 0) throw new FormatException();
 		skip(length);
 		hasLookahead = false;
@@ -291,7 +270,7 @@ class BdfReaderImpl implements BdfReader {
 
 	public void readListStart() throws IOException {
 		if (!hasList()) throw new FormatException();
-		consumeLookahead();
+		hasLookahead = false;
 	}
 
 	public boolean hasListEnd() throws IOException {
@@ -310,7 +289,7 @@ class BdfReaderImpl implements BdfReader {
 
 	private void readEnd() throws IOException {
 		if (!hasEnd()) throw new FormatException();
-		consumeLookahead();
+		hasLookahead = false;
 	}
 
 	public void skipList() throws IOException {
@@ -328,7 +307,7 @@ class BdfReaderImpl implements BdfReader {
 
 	public void readDictionaryStart() throws IOException {
 		if (!hasDictionary()) throw new FormatException();
-		consumeLookahead();
+		hasLookahead = false;
 	}
 
 	public boolean hasDictionaryEnd() throws IOException {
diff --git a/briar-core/src/org/briarproject/data/MetadataEncoderImpl.java b/briar-core/src/org/briarproject/data/MetadataEncoderImpl.java
index f476df113476a9bf234c43a3891b9a28a5771d62..e70070391bdbcad505ac24eaf7b640e55890fca9 100644
--- a/briar-core/src/org/briarproject/data/MetadataEncoderImpl.java
+++ b/briar-core/src/org/briarproject/data/MetadataEncoderImpl.java
@@ -11,6 +11,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
 
+import static org.briarproject.api.data.BdfDictionary.NULL_VALUE;
 import static org.briarproject.api.db.Metadata.REMOVE;
 import static org.briarproject.data.Types.DICTIONARY;
 import static org.briarproject.data.Types.END;
@@ -37,7 +38,7 @@ class MetadataEncoderImpl implements MetadataEncoder {
 		Metadata m = new Metadata();
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
 		for (Entry<String, Object> e : d.entrySet()) {
-			if (e.getValue() == null) {
+			if (e.getValue() == NULL_VALUE) {
 				// Special case: if the value is null, the key is being removed
 				m.put(e.getKey(), REMOVE);
 			} else {
@@ -51,7 +52,7 @@ class MetadataEncoderImpl implements MetadataEncoder {
 
 	private void encodeObject(ByteArrayOutputStream out, Object o)
 			throws FormatException {
-		if (o == null) out.write(NULL);
+		if (o == NULL_VALUE) out.write(NULL);
 		else if (o instanceof Boolean) out.write((Boolean) o ? TRUE : FALSE);
 		else if (o instanceof Byte) encodeInteger(out, (Byte) o);
 		else if (o instanceof Short) encodeInteger(out, (Short) o);
diff --git a/briar-core/src/org/briarproject/data/MetadataParserImpl.java b/briar-core/src/org/briarproject/data/MetadataParserImpl.java
index ce4c71a3a1f0b09b7872afb5f6a92be76f50f986..eadaa34771ac34d624ec9a137cbdfbe31b7a8b9c 100644
--- a/briar-core/src/org/briarproject/data/MetadataParserImpl.java
+++ b/briar-core/src/org/briarproject/data/MetadataParserImpl.java
@@ -10,6 +10,7 @@ import org.briarproject.util.StringUtils;
 import java.io.ByteArrayInputStream;
 import java.util.Map.Entry;
 
+import static org.briarproject.api.data.BdfDictionary.NULL_VALUE;
 import static org.briarproject.api.db.Metadata.REMOVE;
 import static org.briarproject.data.Types.DICTIONARY;
 import static org.briarproject.data.Types.END;
@@ -33,14 +34,14 @@ class MetadataParserImpl implements MetadataParser {
 
 	@Override
 	public BdfDictionary parse(Metadata m) throws FormatException {
-		BdfDictionary dict = new BdfDictionary();
+		BdfDictionary d = new BdfDictionary();
 		for (Entry<String, byte[]> e : m.entrySet())
-			dict.put(e.getKey(), parseObject(e.getValue()));
-		return dict;
+			d.put(e.getKey(), parseValue(e.getValue()));
+		return d;
 	}
 
-	private Object parseObject(byte[] b) throws FormatException {
-		if (b == REMOVE) return null;
+	private Object parseValue(byte[] b) throws FormatException {
+		if (b == REMOVE) return NULL_VALUE;
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
 		Object o = parseObject(in);
 		if (in.available() > 0) throw new FormatException();
@@ -50,7 +51,7 @@ class MetadataParserImpl implements MetadataParser {
 	private Object parseObject(ByteArrayInputStream in) throws FormatException {
 		switch(in.read()) {
 			case NULL:
-				return null;
+				return NULL_VALUE;
 			case TRUE:
 				return Boolean.TRUE;
 			case FALSE:
diff --git a/briar-core/src/org/briarproject/db/Database.java b/briar-core/src/org/briarproject/db/Database.java
index 9963267fee251901ff844bad912a4637bec7bd9c..f512b855303e249fc994924cd5baa80737694241 100644
--- a/briar-core/src/org/briarproject/db/Database.java
+++ b/briar-core/src/org/briarproject/db/Database.java
@@ -6,14 +6,16 @@ import org.briarproject.api.TransportProperties;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
 import org.briarproject.api.sync.SubscriptionAck;
 import org.briarproject.api.sync.SubscriptionUpdate;
 import org.briarproject.api.sync.TransportAck;
@@ -85,6 +87,13 @@ interface Database<T> {
 	ContactId addContact(T txn, Author remote, AuthorId local)
 			throws DbException;
 
+	/**
+	 * Adds a group to the given contact's subscriptions.
+	 * <p>
+	 * Locking: write.
+	 */
+	void addContactGroup(T txn, ContactId c, Group g) throws DbException;
+
 	/**
 	 * Subscribes to a group, or returns false if the user already has the
 	 * maximum number of subscriptions.
@@ -216,11 +225,12 @@ interface Database<T> {
 	int countOfferedMessages(T txn, ContactId c) throws DbException;
 
 	/**
-	 * Returns all groups to which the user could subscribe.
+	 * Returns all groups belonging to the given client to which the user could
+	 * subscribe.
 	 * <p>
 	 * Locking: read.
 	 */
-	Collection<Group> getAvailableGroups(T txn) throws DbException;
+	Collection<Group> getAvailableGroups(T txn, ClientId c) throws DbException;
 
 	/**
 	 * Returns the contact with the given ID.
@@ -265,28 +275,12 @@ interface Database<T> {
 	Group getGroup(T txn, GroupId g) throws DbException;
 
 	/**
-	 * Returns all groups to which the user subscribes, excluding inboxes.
-	 * <p>
-	 * Locking: read.
-	 */
-	Collection<Group> getGroups(T txn) throws DbException;
-
-	/**
-	 * Returns the ID of the inbox group for the given contact, or null if no
-	 * inbox group has been set.
+	 * Returns all groups belonging to the given client to which the user
+	 * subscribes.
 	 * <p>
 	 * Locking: read.
 	 */
-	GroupId getInboxGroupId(T txn, ContactId c) throws DbException;
-
-	/**
-	 * Returns the headers of all messages in the inbox group for the given
-	 * contact, or null if no inbox group has been set.
-	 * <p>
-	 * Locking: read.
-	 */
-	Collection<MessageHeader> getInboxMessageHeaders(T txn, ContactId c)
-			throws DbException;
+	Collection<Group> getGroups(T txn, ClientId c) throws DbException;
 
 	/**
 	 * Returns the local pseudonym with the given ID.
@@ -319,19 +313,37 @@ interface Database<T> {
 			throws DbException;
 
 	/**
-	 * Returns the body of the message identified by the given ID.
+	 * Returns the metadata for all messages in the given group.
 	 * <p>
 	 * Locking: read.
 	 */
-	byte[] getMessageBody(T txn, MessageId m) throws DbException;
+	Map<MessageId, Metadata> getMessageMetadata(T txn, GroupId g)
+			throws DbException;
 
 	/**
-	 * Returns the headers of all messages in the given group.
+	 * Returns the metadata for the given message.
 	 * <p>
 	 * Locking: read.
 	 */
-	Collection<MessageHeader> getMessageHeaders(T txn, GroupId g)
-			throws DbException;
+	Metadata getMessageMetadata(T txn, MessageId m) throws DbException;
+
+	/**
+	 * Returns the status of all messages in the given group with respect
+	 * to the given contact.
+	 * <p>
+	 * Locking: read
+	 */
+	Collection<MessageStatus> getMessageStatus(T txn, ContactId c, GroupId g)
+		throws DbException;
+
+	/**
+	 * Returns the status of the given message with respect to the given
+	 * contact.
+	 * <p>
+	 * Locking: read
+	 */
+	MessageStatus getMessageStatus(T txn, ContactId c, MessageId m)
+		throws DbException;
 
 	/**
 	 * Returns the IDs of some messages received from the given contact that
@@ -370,28 +382,21 @@ interface Database<T> {
 			int maxMessages) throws DbException;
 
 	/**
-	 * Returns the parent of the given message, or null if either the message
-	 * has no parent, or the parent is absent from the database, or the parent
-	 * belongs to a different group.
+	 * Returns the IDs of any messages that need to be validated by the given
+	 * client.
 	 * <p>
 	 * Locking: read.
 	 */
-	MessageId getParent(T txn, MessageId m) throws DbException;
+	Collection<MessageId> getMessagesToValidate(T txn, ClientId c)
+		throws DbException;
 
 	/**
-	 * Returns the message identified by the given ID, in serialised form.
+	 * Returns the message with the given ID, in serialised form.
 	 * <p>
 	 * Locking: read.
 	 */
 	byte[] getRawMessage(T txn, MessageId m) throws DbException;
 
-	/**
-	 * Returns true if the given message is marked as read.
-	 * <p>
-	 * Locking: read.
-	 */
-	boolean getReadFlag(T txn, MessageId m) throws DbException;
-
 	/**
 	 * Returns all remote properties for the given transport.
 	 * <p>
@@ -475,13 +480,6 @@ interface Database<T> {
 	Collection<TransportUpdate> getTransportUpdates(T txn, ContactId c,
 			int maxLatency) throws DbException;
 
-	/**
-	 * Returns the number of unread messages in each subscribed group.
-	 * <p>
-	 * Locking: read.
-	 */
-	Map<GroupId, Integer> getUnreadMessageCounts(T txn) throws DbException;
-
 	/**
 	 * Returns the IDs of all contacts to which the given group is visible.
 	 * <p>
@@ -525,6 +523,15 @@ interface Database<T> {
 	void mergeLocalProperties(T txn, TransportId t, TransportProperties p)
 			throws DbException;
 
+	/*
+	 * Merges the given metadata with the existing metadata for the given
+	 * message.
+	 * <p>
+	 * Locking: write.
+	 */
+	void mergeMessageMetadata(T txn, MessageId m, Metadata meta)
+			throws DbException;
+
 	/**
 	 * Merges the given settings with the existing settings in the given
 	 * namespace.
@@ -624,6 +631,10 @@ interface Database<T> {
 	 */
 	void resetExpiryTime(T txn, ContactId c, MessageId m) throws DbException;
 
+	/** Marks the given message as valid or invalid. */
+	void setMessageValidity(T txn, MessageId m, boolean valid)
+			throws DbException;
+
 	/**
 	 * Sets the reordering window for the given contact and transport in the
 	 * given rotation period.
@@ -634,30 +645,15 @@ interface Database<T> {
 			long rotationPeriod, long base, byte[] bitmap) throws DbException;
 
 	/**
-	 * Updates the groups to which the given contact subscribes and returns
-	 * true, unless an update with an equal or higher version number has
-	 * already been received from the contact.
+	 * Updates the given contact's subscriptions and returns true, unless an
+	 * update with an equal or higher version number has already been received
+	 * from the contact.
 	 * <p>
 	 * Locking: write.
 	 */
 	boolean setGroups(T txn, ContactId c, Collection<Group> groups,
 			long version) throws DbException;
 
-	/**
-	 * Makes a group visible to the given contact, adds it to the contact's
-	 * subscriptions, and sets it as the inbox group for the contact.
-	 * <p>
-	 * Locking: write.
-	 */
-	void setInboxGroup(T txn, ContactId c, Group g) throws DbException;
-
-	/**
-	 * Marks a message as read or unread.
-	 * <p>
-	 * Locking: write.
-	 */
-	void setReadFlag(T txn, MessageId m, boolean read) throws DbException;
-
 	/**
 	 * Sets the remote transport properties for the given contact, replacing
 	 * any existing properties.
diff --git a/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java b/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java
index 64e6e831668c6209b8e37f1dbd0649ba539faa59..3378b8ff537e468bc58a476ddf6bf3cf922daf48 100644
--- a/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java
+++ b/briar-core/src/org/briarproject/db/DatabaseComponentImpl.java
@@ -9,6 +9,8 @@ import org.briarproject.api.db.ContactExistsException;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.LocalAuthorExistsException;
+import org.briarproject.api.db.MessageExistsException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.db.NoSuchContactException;
 import org.briarproject.api.db.NoSuchLocalAuthorException;
 import org.briarproject.api.db.NoSuchMessageException;
@@ -25,6 +27,7 @@ import org.briarproject.api.event.MessageAddedEvent;
 import org.briarproject.api.event.MessageRequestedEvent;
 import org.briarproject.api.event.MessageToAckEvent;
 import org.briarproject.api.event.MessageToRequestEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.event.MessagesAckedEvent;
 import org.briarproject.api.event.MessagesSentEvent;
 import org.briarproject.api.event.RemoteSubscriptionsUpdatedEvent;
@@ -39,11 +42,12 @@ import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.lifecycle.ShutdownManager;
 import org.briarproject.api.sync.Ack;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.Request;
 import org.briarproject.api.sync.SubscriptionAck;
@@ -165,6 +169,22 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		return c;
 	}
 
+	public void addContactGroup(ContactId c, Group g) throws DbException {
+		lock.writeLock().lock();
+		try {
+			T txn = db.startTransaction();
+			try {
+				db.addContactGroup(txn, c, g);
+				db.commitTransaction(txn);
+			} catch (DbException e) {
+				db.abortTransaction(txn);
+				throw e;
+			}
+		} finally {
+			lock.writeLock().unlock();
+		}
+	}
+
 	public boolean addGroup(Group g) throws DbException {
 		boolean added = false;
 		lock.writeLock().lock();
@@ -204,15 +224,18 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		eventBus.broadcast(new LocalAuthorAddedEvent(a.getId()));
 	}
 
-	public void addLocalMessage(Message m) throws DbException {
-		boolean duplicate, subscribed;
+	public void addLocalMessage(Message m, ClientId c, Metadata meta)
+			throws DbException {
 		lock.writeLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				duplicate = db.containsMessage(txn, m.getId());
-				subscribed = db.containsGroup(txn, m.getGroup().getId());
-				if (!duplicate && subscribed) addMessage(txn, m, null);
+				if (db.containsMessage(txn, m.getId()))
+					throw new MessageExistsException();
+				if (!db.containsGroup(txn, m.getGroupId()))
+					throw new NoSuchSubscriptionException();
+				addMessage(txn, m, null);
+				db.mergeMessageMetadata(txn, m.getId(), meta);
 				db.commitTransaction(txn);
 			} catch (DbException e) {
 				db.abortTransaction(txn);
@@ -221,28 +244,21 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		} finally {
 			lock.writeLock().unlock();
 		}
-		if (!duplicate && subscribed) {
-			eventBus.broadcast(new MessageAddedEvent(m, null));
-		}
+		eventBus.broadcast(new MessageAddedEvent(m, null));
+		eventBus.broadcast(new MessageValidatedEvent(m, c, true, true));
 	}
 
 	/**
-	 * Stores a message, initialises its status with respect to each contact,
-	 * and marks it as read if it was locally generated.
+	 * Stores a message and initialises its status with respect to each contact.
 	 * <p>
 	 * Locking: write.
 	 * @param sender null for a locally generated message.
 	 */
 	private void addMessage(T txn, Message m, ContactId sender)
 			throws DbException {
-		if (sender == null) {
-			db.addMessage(txn, m, true);
-			db.setReadFlag(txn, m.getId(), true);
-		} else {
-			db.addMessage(txn, m, false);
-		}
-		Group g = m.getGroup();
-		Collection<ContactId> visibility = db.getVisibility(txn, g.getId());
+		db.addMessage(txn, m, sender == null);
+		GroupId g = m.getGroupId();
+		Collection<ContactId> visibility = db.getVisibility(txn, g);
 		visibility = new HashSet<ContactId>(visibility);
 		for (ContactId c : db.getContactIds(txn)) {
 			if (visibility.contains(c)) {
@@ -506,12 +522,12 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Collection<Group> getAvailableGroups() throws DbException {
+	public Collection<Group> getAvailableGroups(ClientId c) throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				Collection<Group> groups = db.getAvailableGroups(txn);
+				Collection<Group> groups = db.getAvailableGroups(txn, c);
 				db.commitTransaction(txn);
 				return groups;
 			} catch (DbException e) {
@@ -578,12 +594,12 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Collection<Group> getGroups() throws DbException {
+	public Collection<Group> getGroups(ClientId c) throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				Collection<Group> groups = db.getGroups(txn);
+				Collection<Group> groups = db.getGroups(txn, c);
 				db.commitTransaction(txn);
 				return groups;
 			} catch (DbException e) {
@@ -595,16 +611,16 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public GroupId getInboxGroupId(ContactId c) throws DbException {
+	public LocalAuthor getLocalAuthor(AuthorId a) throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				if (!db.containsContact(txn, c))
-					throw new NoSuchContactException();
-				GroupId inbox = db.getInboxGroupId(txn, c);
+				if (!db.containsLocalAuthor(txn, a))
+					throw new NoSuchLocalAuthorException();
+				LocalAuthor localAuthor = db.getLocalAuthor(txn, a);
 				db.commitTransaction(txn);
-				return inbox;
+				return localAuthor;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -614,18 +630,14 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Collection<MessageHeader> getInboxMessageHeaders(ContactId c)
-			throws DbException {
+	public Collection<LocalAuthor> getLocalAuthors() throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				if (!db.containsContact(txn, c))
-					throw new NoSuchContactException();
-				Collection<MessageHeader> headers =
-						db.getInboxMessageHeaders(txn, c);
+				Collection<LocalAuthor> authors = db.getLocalAuthors(txn);
 				db.commitTransaction(txn);
-				return headers;
+				return authors;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -635,16 +647,16 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public LocalAuthor getLocalAuthor(AuthorId a) throws DbException {
+	public Map<TransportId, TransportProperties> getLocalProperties()
+			throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				if (!db.containsLocalAuthor(txn, a))
-					throw new NoSuchLocalAuthorException();
-				LocalAuthor localAuthor = db.getLocalAuthor(txn, a);
+				Map<TransportId, TransportProperties> properties =
+						db.getLocalProperties(txn);
 				db.commitTransaction(txn);
-				return localAuthor;
+				return properties;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -654,14 +666,17 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Collection<LocalAuthor> getLocalAuthors() throws DbException {
+	public TransportProperties getLocalProperties(TransportId t)
+			throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				Collection<LocalAuthor> authors = db.getLocalAuthors(txn);
+				if (!db.containsTransport(txn, t))
+					throw new NoSuchTransportException();
+				TransportProperties properties = db.getLocalProperties(txn, t);
 				db.commitTransaction(txn);
-				return authors;
+				return properties;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -671,16 +686,15 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Map<TransportId, TransportProperties> getLocalProperties()
+	public Collection<MessageId> getMessagesToValidate(ClientId c)
 			throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				Map<TransportId, TransportProperties> properties =
-						db.getLocalProperties(txn);
+				Collection<MessageId> ids = db.getMessagesToValidate(txn, c);
 				db.commitTransaction(txn);
-				return properties;
+				return ids;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -690,17 +704,37 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public TransportProperties getLocalProperties(TransportId t)
+	public byte[] getRawMessage(MessageId m) throws DbException {
+		lock.readLock().lock();
+		try {
+			T txn = db.startTransaction();
+			try {
+				if (!db.containsMessage(txn, m))
+					throw new NoSuchMessageException();
+				byte[] raw = db.getRawMessage(txn, m);
+				db.commitTransaction(txn);
+				return raw;
+			} catch (DbException e) {
+				db.abortTransaction(txn);
+				throw e;
+			}
+		} finally {
+			lock.readLock().unlock();
+		}
+	}
+
+	public Map<MessageId, Metadata> getMessageMetadata(GroupId g)
 			throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				if (!db.containsTransport(txn, t))
-					throw new NoSuchTransportException();
-				TransportProperties properties = db.getLocalProperties(txn, t);
+				if (!db.containsGroup(txn, g))
+					throw new NoSuchSubscriptionException();
+				Map<MessageId, Metadata> metadata =
+						db.getMessageMetadata(txn, g);
 				db.commitTransaction(txn);
-				return properties;
+				return metadata;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -710,16 +744,16 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public byte[] getMessageBody(MessageId m) throws DbException {
+	public Metadata getMessageMetadata(MessageId m) throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
 				if (!db.containsMessage(txn, m))
 					throw new NoSuchMessageException();
-				byte[] body = db.getMessageBody(txn, m);
+				Metadata metadata = db.getMessageMetadata(txn, m);
 				db.commitTransaction(txn);
-				return body;
+				return metadata;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -729,18 +763,20 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Collection<MessageHeader> getMessageHeaders(GroupId g)
+	public Collection<MessageStatus> getMessageStatus(ContactId c, GroupId g)
 			throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
+				if (!db.containsContact(txn, c))
+					throw new NoSuchContactException();
 				if (!db.containsGroup(txn, g))
 					throw new NoSuchSubscriptionException();
-				Collection<MessageHeader> headers =
-						db.getMessageHeaders(txn, g);
+				Collection<MessageStatus> statuses =
+						db.getMessageStatus(txn, c, g);
 				db.commitTransaction(txn);
-				return headers;
+				return statuses;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -750,16 +786,19 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public boolean getReadFlag(MessageId m) throws DbException {
+	public MessageStatus getMessageStatus(ContactId c, MessageId m)
+			throws DbException {
 		lock.readLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
+				if (!db.containsContact(txn, c))
+					throw new NoSuchContactException();
 				if (!db.containsMessage(txn, m))
 					throw new NoSuchMessageException();
-				boolean read = db.getReadFlag(txn, m);
+				MessageStatus status = db.getMessageStatus(txn, c, m);
 				db.commitTransaction(txn);
-				return read;
+				return status;
 			} catch (DbException e) {
 				db.abortTransaction(txn);
 				throw e;
@@ -862,23 +901,6 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		}
 	}
 
-	public Map<GroupId, Integer> getUnreadMessageCounts() throws DbException {
-		lock.readLock().lock();
-		try {
-			T txn = db.startTransaction();
-			try {
-				Map<GroupId, Integer> counts = db.getUnreadMessageCounts(txn);
-				db.commitTransaction(txn);
-				return counts;
-			} catch (DbException e) {
-				db.abortTransaction(txn);
-				throw e;
-			}
-		} finally {
-			lock.readLock().unlock();
-		}
-	}
-
 	public Collection<ContactId> getVisibility(GroupId g) throws DbException {
 		lock.readLock().lock();
 		try {
@@ -943,6 +965,25 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		if (changed) eventBus.broadcast(new LocalTransportsUpdatedEvent());
 	}
 
+	public void mergeMessageMetadata(MessageId m, Metadata meta)
+			throws DbException {
+		lock.writeLock().lock();
+		try {
+			T txn = db.startTransaction();
+			try {
+				if (!db.containsMessage(txn, m))
+					throw new NoSuchMessageException();
+				db.mergeMessageMetadata(txn, m, meta);
+				db.commitTransaction(txn);
+			} catch (DbException e) {
+				db.abortTransaction(txn);
+				throw e;
+			}
+		} finally {
+			lock.writeLock().unlock();
+		}
+	}
+
 	public void mergeSettings(Settings s, String namespace) throws DbException {
 		boolean changed = false;
 		lock.writeLock().lock();
@@ -998,7 +1039,7 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 				if (!db.containsContact(txn, c))
 					throw new NoSuchContactException();
 				duplicate = db.containsMessage(txn, m.getId());
-				visible = db.containsVisibleGroup(txn, c, m.getGroup().getId());
+				visible = db.containsVisibleGroup(txn, c, m.getGroupId());
 				if (visible) {
 					if (!duplicate) addMessage(txn, m, c);
 					db.raiseAckFlag(txn, c, m.getId());
@@ -1012,9 +1053,8 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 			lock.writeLock().unlock();
 		}
 		if (visible) {
-			if (!duplicate) {
+			if (!duplicate)
 				eventBus.broadcast(new MessageAddedEvent(m, c));
-			}
 			eventBus.broadcast(new MessageToAckEvent(c));
 		}
 	}
@@ -1170,8 +1210,6 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 			try {
 				if (!db.containsContact(txn, c))
 					throw new NoSuchContactException();
-				GroupId g = db.getInboxGroupId(txn, c);
-				if (g != null) db.removeGroup(txn, g);
 				db.removeContact(txn, c);
 				db.commitTransaction(txn);
 			} catch (DbException e) {
@@ -1216,10 +1254,6 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 				if (!db.containsLocalAuthor(txn, a))
 					throw new NoSuchLocalAuthorException();
 				affected = db.getContacts(txn, a);
-				for (ContactId c : affected) {
-					GroupId g = db.getInboxGroupId(txn, c);
-					if (g != null) db.removeGroup(txn, g);
-				}
 				db.removeLocalAuthor(txn, a);
 				db.commitTransaction(txn);
 			} catch (DbException e) {
@@ -1253,32 +1287,15 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		eventBus.broadcast(new TransportRemovedEvent(t));
 	}
 
-	public void setInboxGroup(ContactId c, Group g) throws DbException {
-		lock.writeLock().lock();
-		try {
-			T txn = db.startTransaction();
-			try {
-				if (!db.containsContact(txn, c))
-					throw new NoSuchContactException();
-				db.setInboxGroup(txn, c, g);
-				db.commitTransaction(txn);
-			} catch (DbException e) {
-				db.abortTransaction(txn);
-				throw e;
-			}
-		} finally {
-			lock.writeLock().unlock();
-		}
-	}
-
-	public void setReadFlag(MessageId m, boolean read) throws DbException {
+	public void setMessageValidity(Message m, ClientId c, boolean valid)
+			throws DbException {
 		lock.writeLock().lock();
 		try {
 			T txn = db.startTransaction();
 			try {
-				if (!db.containsMessage(txn, m))
+				if (!db.containsMessage(txn, m.getId()))
 					throw new NoSuchMessageException();
-				db.setReadFlag(txn, m, read);
+				db.setMessageValidity(txn, m.getId(), valid);
 				db.commitTransaction(txn);
 			} catch (DbException e) {
 				db.abortTransaction(txn);
@@ -1287,6 +1304,7 @@ class DatabaseComponentImpl<T> implements DatabaseComponent {
 		} finally {
 			lock.writeLock().unlock();
 		}
+		eventBus.broadcast(new MessageValidatedEvent(m, c, false, valid));
 	}
 
 	public void setRemoteProperties(ContactId c,
diff --git a/briar-core/src/org/briarproject/db/DatabaseConstants.java b/briar-core/src/org/briarproject/db/DatabaseConstants.java
index 25f325ff4d748b7be538cea3ea5a2fd58d26715a..2c06feca19f68c5806fdc9f95c5214634fb9af94 100644
--- a/briar-core/src/org/briarproject/db/DatabaseConstants.java
+++ b/briar-core/src/org/briarproject/db/DatabaseConstants.java
@@ -8,34 +8,4 @@ interface DatabaseConstants {
 	 * limit is reached, additional offers will not be stored.
 	 */
 	int MAX_OFFERED_MESSAGES = 1000;
-
-	// FIXME: These should be configurable
-
-	/**
-	 * The minimum amount of space in bytes that should be kept free on the
-	 * device where the database is stored. Whenever less than this much space
-	 * is free, old messages will be expired from the database.
-	 */
-	long MIN_FREE_SPACE = 50 * 1024 * 1024; // 50 MiB
-
-	/**
-	 * The minimum amount of space in bytes that must be kept free on the device
-	 * where the database is stored. If less than this much space is free and
-	 * there are no more messages to expire, an Error will be thrown.
-	 */
-	long CRITICAL_FREE_SPACE = 10 * 1024 * 1024; // 10 MiB
-
-	/**
-	 * The amount of free space will be checked whenever this many transactions
-	 * have been started since the last check.
-	 * <p>
-	 * FIXME: Increase this after implementing BTPv2 (smaller packets)?
-	 */
-	int MAX_TRANSACTIONS_BETWEEN_SPACE_CHECKS = 10;
-
-	/**
-	 * Up to this many bytes of messages will be expired from the database each
-	 * time it is necessary to expire messages.
-	 */
-	int BYTES_PER_SWEEP = 10 * 1024 * 1024; // 10 MiB
 }
diff --git a/briar-core/src/org/briarproject/db/JdbcDatabase.java b/briar-core/src/org/briarproject/db/JdbcDatabase.java
index b4f5d7932bc5cde9235a1e36690f72e2ca788fc7..80c9a1dd90d26ca7b86bfcf720c792bce3851431 100644
--- a/briar-core/src/org/briarproject/db/JdbcDatabase.java
+++ b/briar-core/src/org/briarproject/db/JdbcDatabase.java
@@ -8,15 +8,16 @@ import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.SecretKey;
 import org.briarproject.api.db.DbClosedException;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
-import org.briarproject.api.sync.MessageHeader.State;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
 import org.briarproject.api.sync.SubscriptionAck;
 import org.briarproject.api.sync.SubscriptionUpdate;
 import org.briarproject.api.sync.TransportAck;
@@ -48,13 +49,9 @@ import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.logging.Logger;
 
-import static java.sql.Types.BINARY;
-import static java.sql.Types.VARCHAR;
 import static java.util.logging.Level.WARNING;
-import static org.briarproject.api.identity.Author.Status.ANONYMOUS;
-import static org.briarproject.api.identity.Author.Status.UNKNOWN;
-import static org.briarproject.api.identity.Author.Status.VERIFIED;
-import static org.briarproject.api.sync.MessagingConstants.MAX_SUBSCRIPTIONS;
+import static org.briarproject.api.db.Metadata.REMOVE;
+import static org.briarproject.api.sync.SyncConstants.MAX_SUBSCRIPTIONS;
 import static org.briarproject.db.ExponentialBackoff.calculateExpiry;
 
 /**
@@ -63,8 +60,12 @@ import static org.briarproject.db.ExponentialBackoff.calculateExpiry;
  */
 abstract class JdbcDatabase implements Database<Connection> {
 
-	private static final int SCHEMA_VERSION = 12;
-	private static final int MIN_SCHEMA_VERSION = 12;
+	private static final int SCHEMA_VERSION = 14;
+	private static final int MIN_SCHEMA_VERSION = 14;
+
+	private static final int VALIDATION_UNKNOWN = 0;
+	private static final int VALIDATION_INVALID = 1;
+	private static final int VALIDATION_VALID = 2;
 
 	private static final String CREATE_SETTINGS =
 			"CREATE TABLE settings"
@@ -98,8 +99,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 	private static final String CREATE_GROUPS =
 			"CREATE TABLE groups"
 					+ " (groupId HASH NOT NULL,"
-					+ " name VARCHAR NOT NULL,"
-					+ " salt BINARY NOT NULL,"
+					+ " clientId HASH NOT NULL,"
+					+ " descriptor BINARY NOT NULL,"
 					+ " visibleToAll BOOLEAN NOT NULL,"
 					+ " PRIMARY KEY (groupId))";
 
@@ -107,7 +108,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 			"CREATE TABLE groupVisibilities"
 					+ " (contactId INT NOT NULL,"
 					+ " groupId HASH NOT NULL,"
-					+ " inbox BOOLEAN NOT NULL,"
 					+ " PRIMARY KEY (contactId, groupId),"
 					+ " FOREIGN KEY (contactId)"
 					+ " REFERENCES contacts (contactId)"
@@ -120,8 +120,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			"CREATE TABLE contactGroups"
 					+ " (contactId INT NOT NULL,"
 					+ " groupId HASH NOT NULL," // Not a foreign key
-					+ " name VARCHAR NOT NULL,"
-					+ " salt BINARY NOT NULL,"
+					+ " clientId HASH NOT NULL,"
+					+ " descriptor BINARY NOT NULL,"
 					+ " PRIMARY KEY (contactId, groupId),"
 					+ " FOREIGN KEY (contactId)"
 					+ " REFERENCES contacts (contactId)"
@@ -144,26 +144,26 @@ abstract class JdbcDatabase implements Database<Connection> {
 	private static final String CREATE_MESSAGES =
 			"CREATE TABLE messages"
 					+ " (messageId HASH NOT NULL,"
-					+ " parentId HASH," // Null for the first msg in a thread
 					+ " groupId HASH NOT NULL,"
-					+ " authorId HASH," // Null for private/anon messages
-					+ " authorName VARCHAR," // Null for private/anon messages
-					+ " authorKey VARCHAR," // Null for private/anon messages
-					+ " contentType VARCHAR NOT NULL,"
 					+ " timestamp BIGINT NOT NULL,"
+					+ " local BOOLEAN NOT NULL,"
+					+ " valid INT NOT NULL,"
 					+ " length INT NOT NULL,"
-					+ " bodyStart INT NOT NULL,"
-					+ " bodyLength INT NOT NULL,"
 					+ " raw BLOB NOT NULL,"
-					+ " local BOOLEAN NOT NULL,"
-					+ " read BOOLEAN NOT NULL,"
 					+ " PRIMARY KEY (messageId),"
 					+ " FOREIGN KEY (groupId)"
 					+ " REFERENCES groups (groupId)"
 					+ " ON DELETE CASCADE)";
 
-	private static final String INDEX_MESSAGES_BY_TIMESTAMP =
-			"CREATE INDEX messagesByTimestamp ON messages (timestamp)";
+	private static final String CREATE_MESSAGE_METADATA =
+			"CREATE TABLE messageMetadata"
+					+ " (messageId HASH NOT NULL,"
+					+ " key VARCHAR NOT NULL,"
+					+ " value BINARY NOT NULL,"
+					+ " PRIMARY KEY (messageId, key),"
+					+ " FOREIGN KEY (messageId)"
+					+ " REFERENCES messages (messageId)"
+					+ " ON DELETE CASCADE)";
 
 	private static final String CREATE_OFFERS =
 			"CREATE TABLE offers"
@@ -191,12 +191,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " REFERENCES contacts (contactId)"
 					+ " ON DELETE CASCADE)";
 
-	private static final String INDEX_STATUSES_BY_MESSAGE =
-			"CREATE INDEX statusesByMessage ON statuses (messageId)";
-
-	private static final String INDEX_STATUSES_BY_CONTACT =
-			"CREATE INDEX statusesByContact ON statuses (contactId)";
-
 	private static final String CREATE_TRANSPORTS =
 			"CREATE TABLE transports"
 					+ " (transportId VARCHAR NOT NULL,"
@@ -393,11 +387,9 @@ abstract class JdbcDatabase implements Database<Connection> {
 			s.executeUpdate(insertTypeNames(CREATE_CONTACT_GROUPS));
 			s.executeUpdate(insertTypeNames(CREATE_GROUP_VERSIONS));
 			s.executeUpdate(insertTypeNames(CREATE_MESSAGES));
-			s.executeUpdate(INDEX_MESSAGES_BY_TIMESTAMP);
+			s.executeUpdate(insertTypeNames(CREATE_MESSAGE_METADATA));
 			s.executeUpdate(insertTypeNames(CREATE_OFFERS));
 			s.executeUpdate(insertTypeNames(CREATE_STATUSES));
-			s.executeUpdate(INDEX_STATUSES_BY_MESSAGE);
-			s.executeUpdate(INDEX_STATUSES_BY_CONTACT);
 			s.executeUpdate(insertTypeNames(CREATE_TRANSPORTS));
 			s.executeUpdate(insertTypeNames(CREATE_TRANSPORT_CONFIGS));
 			s.executeUpdate(insertTypeNames(CREATE_TRANSPORT_PROPS));
@@ -596,9 +588,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			rs.close();
 			ps.close();
 			if (!ids.isEmpty()) {
-				sql = "INSERT INTO groupVisibilities"
-						+ " (contactId, groupId, inbox)"
-						+ " VALUES (?, ?, FALSE)";
+				sql = "INSERT INTO groupVisibilities (contactId, groupId)"
+						+ " VALUES (?, ?)";
 				ps = txn.prepareStatement(sql);
 				ps.setInt(1, c.getInt());
 				for (byte[] id : ids) {
@@ -656,6 +647,40 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public void addContactGroup(Connection txn, ContactId c, Group g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM contactGroups"
+					+ " WHERE contactId = ? AND groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getId().getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			if (found) return;
+			sql = "INSERT INTO contactGroups"
+					+ " (contactId, groupId, clientId, descriptor)"
+					+ " VALUES (?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getId().getBytes());
+			ps.setBytes(3, g.getClientId().getBytes());
+			ps.setBytes(4, g.getDescriptor());
+			int affected = ps.executeUpdate();
+			if (affected != 1) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public boolean addGroup(Connection txn, Group g) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
@@ -671,12 +696,12 @@ abstract class JdbcDatabase implements Database<Connection> {
 			if (count > MAX_SUBSCRIPTIONS) throw new DbStateException();
 			if (count == MAX_SUBSCRIPTIONS) return false;
 			sql = "INSERT INTO groups"
-					+ " (groupId, name, salt, visibleToAll)"
+					+ " (groupId, clientId, descriptor, visibleToAll)"
 					+ " VALUES (?, ?, ?, FALSE)";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, g.getId().getBytes());
-			ps.setString(2, g.getName());
-			ps.setBytes(3, g.getSalt());
+			ps.setBytes(2, g.getClientId().getBytes());
+			ps.setBytes(3, g.getDescriptor());
 			int affected = ps.executeUpdate();
 			if (affected != 1) throw new DbStateException();
 			ps.close();
@@ -714,34 +739,18 @@ abstract class JdbcDatabase implements Database<Connection> {
 			throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "INSERT INTO messages (messageId, parentId, groupId,"
-					+ " authorId, authorName, authorKey, contentType,"
-					+ " timestamp, length, bodyStart, bodyLength, raw,"
-					+ " local, read)"
-					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)";
+			String sql = "INSERT INTO messages (messageId, groupId, timestamp,"
+					+ " local, valid, length, raw)"
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, m.getId().getBytes());
-			if (m.getParent() == null) ps.setNull(2, BINARY);
-			else ps.setBytes(2, m.getParent().getBytes());
-			ps.setBytes(3, m.getGroup().getId().getBytes());
-			Author a = m.getAuthor();
-			if (a == null) {
-				ps.setNull(4, BINARY);
-				ps.setNull(5, VARCHAR);
-				ps.setNull(6, BINARY);
-			} else {
-				ps.setBytes(4, a.getId().getBytes());
-				ps.setString(5, a.getName());
-				ps.setBytes(6, a.getPublicKey());
-			}
-			ps.setString(7, m.getContentType());
-			ps.setLong(8, m.getTimestamp());
-			byte[] raw = m.getSerialised();
-			ps.setInt(9, raw.length);
-			ps.setInt(10, m.getBodyStart());
-			ps.setInt(11, m.getBodyLength());
-			ps.setBytes(12, raw);
-			ps.setBoolean(13, local);
+			ps.setBytes(2, m.getGroupId().getBytes());
+			ps.setLong(3, m.getTimestamp());
+			ps.setBoolean(4, local);
+			ps.setInt(5, local ? VALIDATION_VALID : VALIDATION_UNKNOWN);
+			byte[] raw = m.getRaw();
+			ps.setInt(6, raw.length);
+			ps.setBytes(7, raw);
 			int affected = ps.executeUpdate();
 			if (affected != 1) throw new DbStateException();
 			ps.close();
@@ -924,9 +933,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "INSERT INTO groupVisibilities"
-					+ " (contactId, groupId, inbox)"
-					+ " VALUES (?, ?, FALSE)";
+			String sql = "INSERT INTO groupVisibilities (contactId, groupId)"
+					+ " VALUES (?, ?)";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
 			ps.setBytes(2, g.getBytes());
@@ -1147,27 +1155,28 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<Group> getAvailableGroups(Connection txn)
+	public Collection<Group> getAvailableGroups(Connection txn, ClientId c)
 			throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT DISTINCT cg.groupId, cg.name, cg.salt"
+			String sql = "SELECT DISTINCT cg.groupId, cg.descriptor"
 					+ " FROM contactGroups AS cg"
 					+ " LEFT OUTER JOIN groups AS g"
 					+ " ON cg.groupId = g.groupId"
-					+ " WHERE g.groupId IS NULL"
+					+ " WHERE cg.clientId = ?"
+					+ " AND g.groupId IS NULL"
 					+ " GROUP BY cg.groupId";
 			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, c.getBytes());
 			rs = ps.executeQuery();
 			List<Group> groups = new ArrayList<Group>();
 			Set<GroupId> ids = new HashSet<GroupId>();
 			while (rs.next()) {
 				GroupId id = new GroupId(rs.getBytes(1));
 				if (!ids.add(id)) throw new DbStateException();
-				String name = rs.getString(2);
-				byte[] salt = rs.getBytes(3);
-				groups.add(new Group(id, name, salt));
+				byte[] descriptor = rs.getBytes(2);
+				groups.add(new Group(id, c, descriptor));
 			}
 			rs.close();
 			ps.close();
@@ -1281,16 +1290,17 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT name, salt FROM groups WHERE groupId = ?";
+			String sql = "SELECT clientId, descriptor FROM groups"
+					+ " WHERE groupId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, g.getBytes());
 			rs = ps.executeQuery();
 			if (!rs.next()) throw new DbStateException();
-			String name = rs.getString(1);
-			byte[] salt = rs.getBytes(2);
+			ClientId clientId = new ClientId(rs.getBytes(1));
+			byte[] descriptor = rs.getBytes(2);
 			rs.close();
 			ps.close();
-			return new Group(g, name, salt);
+			return new Group(g, clientId, descriptor);
 		} catch (SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1298,24 +1308,21 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<Group> getGroups(Connection txn) throws DbException {
+	public Collection<Group> getGroups(Connection txn, ClientId c)
+			throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT DISTINCT g.groupId, name, salt"
-					+ " FROM groups AS g"
-					+ " LEFT OUTER JOIN groupVisibilities AS gv"
-					+ " ON g.groupId = gv.groupId"
-					+ " WHERE gv.inbox IS NULL OR gv.inbox = FALSE"
-					+ " GROUP BY g.groupId";
+			String sql = "SELECT groupId, descriptor FROM groups"
+					+ " WHERE clientId = ?";
 			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, c.getBytes());
 			rs = ps.executeQuery();
 			List<Group> groups = new ArrayList<Group>();
 			while (rs.next()) {
 				GroupId id = new GroupId(rs.getBytes(1));
-				String name = rs.getString(2);
-				byte[] salt = rs.getBytes(3);
-				groups.add(new Group(id, name, salt));
+				byte[] descriptor = rs.getBytes(2);
+				groups.add(new Group(id, c, descriptor));
 			}
 			rs.close();
 			ps.close();
@@ -1327,103 +1334,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public GroupId getInboxGroupId(Connection txn, ContactId c)
-			throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT groupId FROM groupVisibilities"
-					+ " WHERE contactId = ?"
-					+ " AND inbox = TRUE";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			GroupId inbox = null;
-			if (rs.next()) inbox = new GroupId(rs.getBytes(1));
-			if (rs.next()) throw new DbStateException();
-			rs.close();
-			ps.close();
-			return inbox;
-		} catch (SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
-	public Collection<MessageHeader> getInboxMessageHeaders(Connection txn,
-			ContactId c) throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			// Get the local and remote authors
-			String sql = "SELECT la.authorId, la.name, la.publicKey,"
-					+ " c.authorId, c.name, c.publicKey"
-					+ " FROM localAuthors AS la"
-					+ " JOIN contacts AS c"
-					+ " ON la.authorId = c.localAuthorId"
-					+ " WHERE contactId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			if (!rs.next()) throw new DbException();
-			AuthorId localId = new AuthorId(rs.getBytes(1));
-			String localName = rs.getString(2);
-			byte[] localKey = rs.getBytes(3);
-			Author localAuthor = new Author(localId, localName, localKey);
-			AuthorId remoteId = new AuthorId(rs.getBytes(4));
-			String remoteName = rs.getString(5);
-			byte[] remoteKey = rs.getBytes(6);
-			Author remoteAuthor = new Author(remoteId, remoteName, remoteKey);
-			if (rs.next()) throw new DbException();
-			// Get the message headers
-			sql = "SELECT m.messageId, parentId, m.groupId, contentType,"
-					+ " timestamp, local, read, seen, s.txCount"
-					+ " FROM messages AS m"
-					+ " JOIN groups AS g"
-					+ " ON m.groupId = g.groupId"
-					+ " JOIN groupVisibilities AS gv"
-					+ " ON m.groupId = gv.groupId"
-					+ " JOIN statuses AS s"
-					+ " ON m.messageId = s.messageId"
-					+ " AND gv.contactId = s.contactId"
-					+ " WHERE gv.contactId = ?"
-					+ " AND inbox = TRUE";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			List<MessageHeader> headers = new ArrayList<MessageHeader>();
-			while (rs.next()) {
-				MessageId id = new MessageId(rs.getBytes(1));
-				byte[] b = rs.getBytes(2);
-				MessageId parent = b == null ? null : new MessageId(b);
-				GroupId groupId = new GroupId(rs.getBytes(3));
-				String contentType = rs.getString(4);
-				long timestamp = rs.getLong(5);
-				boolean local = rs.getBoolean(6);
-				boolean read = rs.getBoolean(7);
-				boolean seen = rs.getBoolean(8);
-				Author author = local ? localAuthor : remoteAuthor;
-
-				// initialize message status
-				State status;
-				if (seen) status = State.DELIVERED;
-				else if (rs.getInt(9) > 0) status = State.SENT;
-				else status = State.STORED;
-
-				headers.add(new MessageHeader(id, parent, groupId, author,
-						VERIFIED, contentType, timestamp, local, read, status));
-			}
-			rs.close();
-			ps.close();
-			return Collections.unmodifiableList(headers);
-		} catch (SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public LocalAuthor getLocalAuthor(Connection txn, AuthorId a)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -1538,25 +1448,58 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public byte[] getMessageBody(Connection txn, MessageId m)
+	public Map<MessageId, Metadata> getMessageMetadata(Connection txn,
+			GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT m.messageId, key, value"
+					+ " FROM messages AS m"
+					+ " JOIN messageMetadata AS md"
+					+ " ON m.messageId = md.messageId"
+					+ " WHERE groupId = ?"
+					+ " ORDER BY m.messageId";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Map<MessageId, Metadata> all = new HashMap<MessageId, Metadata>();
+			Metadata metadata = null;
+			MessageId lastMessageId = null;
+			while (rs.next()) {
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				if (!messageId.equals(lastMessageId)) {
+					metadata = new Metadata();
+					all.put(messageId, metadata);
+					lastMessageId = messageId;
+				}
+				metadata.put(rs.getString(2), rs.getBytes(3));
+			}
+			rs.close();
+			ps.close();
+			return Collections.unmodifiableMap(all);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	public Metadata getMessageMetadata(Connection txn, MessageId m)
 			throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT bodyStart, bodyLength, raw FROM messages"
+			String sql = "SELECT key, value"
+					+ " FROM messageMetadata"
 					+ " WHERE messageId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, m.getBytes());
 			rs = ps.executeQuery();
-			if (!rs.next()) throw new DbStateException();
-			int bodyStart = rs.getInt(1);
-			int bodyLength = rs.getInt(2);
-			// Bytes are indexed from 1 rather than 0
-			byte[] body = rs.getBlob(3).getBytes(bodyStart + 1, bodyLength);
-			if (rs.next()) throw new DbStateException();
+			Metadata metadata = new Metadata();
+			while (rs.next()) metadata.put(rs.getString(1), rs.getBytes(2));
 			rs.close();
 			ps.close();
-			return body;
+			return metadata;
 		} catch (SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1564,60 +1507,58 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	/**
-	 * This method is used to get group messages.
-	 * The message status won't be used.
-	 */
-	public Collection<MessageHeader> getMessageHeaders(Connection txn,
-			GroupId g) throws DbException {
+	public Collection<MessageStatus> getMessageStatus(Connection txn,
+			ContactId c, GroupId g) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT messageId, parentId, m.authorId, authorName,"
-					+ " authorKey, contentType, timestamp, local, read,"
-					+ " la.authorId IS NOT NULL, c.authorId IS NOT NULL"
+			String sql = "SELECT m.messageId, txCount > 0, seen"
 					+ " FROM messages AS m"
-					+ " LEFT OUTER JOIN localAuthors AS la"
-					+ " ON m.authorId = la.authorId"
-					+ " LEFT OUTER JOIN contacts AS c"
-					+ " ON m.authorId = c.authorId"
-					+ " WHERE groupId = ?";
+					+ " JOIN statuses AS s"
+					+ " ON m.messageId = s.messageId"
+					+ " WHERE groupId = ?"
+					+ " AND contactId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, g.getBytes());
+			ps.setInt(2, c.getInt());
 			rs = ps.executeQuery();
-			List<MessageHeader> headers = new ArrayList<MessageHeader>();
+			List<MessageStatus> statuses = new ArrayList<MessageStatus>();
 			while (rs.next()) {
-				MessageId id = new MessageId(rs.getBytes(1));
-				byte[] b = rs.getBytes(2);
-				MessageId parent = b == null ? null : new MessageId(b);
-				Author author;
-				b = rs.getBytes(3);
-				if (b == null) {
-					author = null;
-				} else {
-					AuthorId authorId = new AuthorId(b);
-					String authorName = rs.getString(4);
-					byte[] authorKey = rs.getBytes(5);
-					author = new Author(authorId, authorName, authorKey);
-				}
-				String contentType = rs.getString(6);
-				long timestamp = rs.getLong(7);
-				boolean local = rs.getBoolean(8);
-				boolean read = rs.getBoolean(9);
-				boolean isSelf = rs.getBoolean(10);
-				boolean isContact = rs.getBoolean(11);
-
-				Author.Status status;
-				if (author == null) status = ANONYMOUS;
-				else if (isSelf || isContact) status = VERIFIED;
-				else status = UNKNOWN;
-
-				headers.add(new MessageHeader(id, parent, g, author, status,
-						contentType, timestamp, local, read, State.STORED));
+				MessageId messageId = new MessageId(rs.getBytes(1));
+				boolean sent = rs.getBoolean(2);
+				boolean seen = rs.getBoolean(3);
+				statuses.add(new MessageStatus(messageId, c, sent, seen));
 			}
 			rs.close();
 			ps.close();
-			return Collections.unmodifiableList(headers);
+			return Collections.unmodifiableList(statuses);
+		} catch (SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	public MessageStatus getMessageStatus(Connection txn,
+			ContactId c, MessageId m) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT txCount > 0, seen"
+					+ " FROM statuses"
+					+ " WHERE messageId = ?"
+					+ " AND contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			ps.setInt(2, c.getInt());
+			rs = ps.executeQuery();
+			if (!rs.next()) throw new DbStateException();
+			boolean sent = rs.getBoolean(1);
+			boolean seen = rs.getBoolean(2);
+			if (rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return new MessageStatus(m, c, sent, seen);
 		} catch (SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1665,13 +1606,15 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " ON m.messageId = s.messageId"
 					+ " AND cg.contactId = s.contactId"
 					+ " WHERE cg.contactId = ?"
+					+ " AND valid = ?"
 					+ " AND seen = FALSE AND requested = FALSE"
 					+ " AND s.expiry < ?"
 					+ " ORDER BY timestamp DESC LIMIT ?";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
-			ps.setLong(2, now);
-			ps.setInt(3, maxMessages);
+			ps.setInt(2, VALIDATION_VALID);
+			ps.setLong(3, now);
+			ps.setInt(4, maxMessages);
 			rs = ps.executeQuery();
 			List<MessageId> ids = new ArrayList<MessageId>();
 			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
@@ -1725,12 +1668,14 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " ON m.messageId = s.messageId"
 					+ " AND cg.contactId = s.contactId"
 					+ " WHERE cg.contactId = ?"
+					+ " AND valid = ?"
 					+ " AND seen = FALSE"
 					+ " AND s.expiry < ?"
 					+ " ORDER BY timestamp DESC";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
-			ps.setLong(2, now);
+			ps.setInt(2, VALIDATION_VALID);
+			ps.setLong(3, now);
 			rs = ps.executeQuery();
 			List<MessageId> ids = new ArrayList<MessageId>();
 			int total = 0;
@@ -1750,26 +1695,23 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public MessageId getParent(Connection txn, MessageId m) throws DbException {
+	public Collection<MessageId> getMessagesToValidate(Connection txn,
+			ClientId c) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT m1.parentId FROM messages AS m1"
-					+ " JOIN messages AS m2"
-					+ " ON m1.parentId = m2.messageId"
-					+ " AND m1.groupId = m2.groupId"
-					+ " WHERE m1.messageId = ?";
+			String sql = "SELECT messageId FROM messages AS m"
+					+ " JOIN groups AS g ON m.groupId = g.groupId"
+					+ " WHERE valid = ? AND clientId = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, m.getBytes());
+			ps.setInt(1, VALIDATION_UNKNOWN);
+			ps.setBytes(2, c.getBytes());
 			rs = ps.executeQuery();
-			MessageId parent = null;
-			if (rs.next()) {
-				parent = new MessageId(rs.getBytes(1));
-				if (rs.next()) throw new DbStateException();
-			}
+			List<MessageId> ids = new ArrayList<MessageId>();
+			while (rs.next()) ids.add(new MessageId(rs.getBytes(1)));
 			rs.close();
 			ps.close();
-			return parent;
+			return Collections.unmodifiableList(ids);
 		} catch (SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1782,14 +1724,12 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT length, raw FROM messages WHERE messageId = ?";
+			String sql = "SELECT raw FROM messages WHERE messageId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, m.getBytes());
 			rs = ps.executeQuery();
 			if (!rs.next()) throw new DbStateException();
-			int length = rs.getInt(1);
-			byte[] raw = rs.getBlob(2).getBytes(1, length);
-			if (raw.length != length) throw new DbStateException();
+			byte[] raw = rs.getBytes(1);
 			if (rs.next()) throw new DbStateException();
 			rs.close();
 			ps.close();
@@ -1801,27 +1741,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean getReadFlag(Connection txn, MessageId m) throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT read FROM messages WHERE messageId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, m.getBytes());
-			rs = ps.executeQuery();
-			if (!rs.next()) throw new DbStateException();
-			boolean read = rs.getBoolean(1);
-			if (rs.next()) throw new DbStateException();
-			rs.close();
-			ps.close();
-			return read;
-		} catch (SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public Map<ContactId, TransportProperties> getRemoteProperties(
 			Connection txn, TransportId t) throws DbException {
 		PreparedStatement ps = null;
@@ -1874,12 +1793,14 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " ON m.messageId = s.messageId"
 					+ " AND cg.contactId = s.contactId"
 					+ " WHERE cg.contactId = ?"
+					+ " AND valid = ?"
 					+ " AND seen = FALSE AND requested = TRUE"
 					+ " AND s.expiry < ?"
 					+ " ORDER BY timestamp DESC";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
-			ps.setLong(2, now);
+			ps.setInt(2, VALIDATION_VALID);
+			ps.setLong(3, now);
 			rs = ps.executeQuery();
 			List<MessageId> ids = new ArrayList<MessageId>();
 			int total = 0;
@@ -1993,7 +1914,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT g.groupId, name, salt, localVersion, txCount"
+			String sql = "SELECT g.groupId, clientId, descriptor,"
+					+ " localVersion, txCount"
 					+ " FROM groups AS g"
 					+ " JOIN groupVisibilities AS gvis"
 					+ " ON g.groupId = gvis.groupId"
@@ -2013,9 +1935,9 @@ abstract class JdbcDatabase implements Database<Connection> {
 			while (rs.next()) {
 				GroupId id = new GroupId(rs.getBytes(1));
 				if (!ids.add(id)) throw new DbStateException();
-				String name = rs.getString(2);
-				byte[] salt = rs.getBytes(3);
-				groups.add(new Group(id, name, salt));
+				ClientId clientId = new ClientId(rs.getBytes(2));
+				byte[] descriptor = rs.getBytes(3);
+				groups.add(new Group(id, clientId, descriptor));
 				version = rs.getLong(4);
 				txCount = rs.getInt(5);
 			}
@@ -2233,32 +2155,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Map<GroupId, Integer> getUnreadMessageCounts(Connection txn)
-			throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT groupId, COUNT(*)"
-					+ " FROM messages AS m"
-					+ " WHERE read = FALSE"
-					+ " GROUP BY groupId";
-			ps = txn.prepareStatement(sql);
-			rs = ps.executeQuery();
-			Map<GroupId, Integer> counts = new HashMap<GroupId, Integer>();
-			while (rs.next()) {
-				GroupId groupId = new GroupId(rs.getBytes(1));
-				counts.put(groupId, rs.getInt(2));
-			}
-			rs.close();
-			ps.close();
-			return Collections.unmodifiableMap(counts);
-		} catch (SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public Collection<ContactId> getVisibility(Connection txn, GroupId g)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -2419,11 +2315,87 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public void mergeMessageMetadata(Connection txn, MessageId m, Metadata meta)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			// Determine which keys are being removed
+			List<String> removed = new ArrayList<String>();
+			Map<String, byte[]> retained = new HashMap<String, byte[]>();
+			for (Entry<String, byte[]> e : meta.entrySet()) {
+				if (e.getValue() == REMOVE) removed.add(e.getKey());
+				else retained.put(e.getKey(), e.getValue());
+			}
+			// Delete any keys that are being removed
+			if (!removed.isEmpty()) {
+				String sql = "DELETE FROM messageMetadata"
+						+ " WHERE messageId = ? AND key = ?";
+				ps = txn.prepareStatement(sql);
+				ps.setBytes(1, m.getBytes());
+				for (String key : removed) {
+					ps.setString(2, key);
+					ps.addBatch();
+				}
+				int[] batchAffected = ps.executeBatch();
+				if (batchAffected.length != removed.size())
+					throw new DbStateException();
+				for (int i = 0; i < batchAffected.length; i++) {
+					if (batchAffected[i] < 0) throw new DbStateException();
+					if (batchAffected[i] > 1) throw new DbStateException();
+				}
+				ps.close();
+			}
+			if (retained.isEmpty()) return;
+			// Update any keys that already exist
+			String sql = "UPDATE messageMetadata SET value = ?"
+					+ " WHERE messageId = ? AND key = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(2, m.getBytes());
+			for (Entry<String, byte[]> e : retained.entrySet()) {
+				ps.setBytes(1, e.getValue());
+				ps.setString(3, e.getKey());
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if (batchAffected.length != retained.size())
+				throw new DbStateException();
+			for (int i = 0; i < batchAffected.length; i++) {
+				if (batchAffected[i] < 0) throw new DbStateException();
+				if (batchAffected[i] > 1) throw new DbStateException();
+			}
+			// Insert any keys that don't already exist
+			sql = "INSERT INTO messageMetadata (messageId, key, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, m.getBytes());
+			int updateIndex = 0, inserted = 0;
+			for (Entry<String, byte[]> e : retained.entrySet()) {
+				if (batchAffected[updateIndex] == 0) {
+					ps.setString(2, e.getKey());
+					ps.setBytes(3, e.getValue());
+					ps.addBatch();
+					inserted++;
+				}
+				updateIndex++;
+			}
+			batchAffected = ps.executeBatch();
+			if (batchAffected.length != inserted) throw new DbStateException();
+			for (int i = 0; i < batchAffected.length; i++) {
+				if (batchAffected[i] != 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public void mergeSettings(Connection txn, Settings s, String namespace) throws DbException {
 		PreparedStatement ps = null;
 		try {
 			// Update any settings that already exist
-			String sql = "UPDATE settings SET value = ? WHERE key = ? AND namespace = ?";
+			String sql = "UPDATE settings SET value = ?"
+					+ " WHERE key = ? AND namespace = ?";
 			ps = txn.prepareStatement(sql);
 			for (Entry<String, String> e : s.entrySet()) {
 				ps.setString(1, e.getValue());
@@ -2438,7 +2410,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 				if (batchAffected[i] > 1) throw new DbStateException();
 			}
 			// Insert any settings that don't already exist
-			sql = "INSERT INTO settings (key, value, namespace) VALUES (?, ?, ?)";
+			sql = "INSERT INTO settings (key, value, namespace)"
+					+ " VALUES (?, ?, ?)";
 			ps = txn.prepareStatement(sql);
 			int updateIndex = 0, inserted = 0;
 			for (Entry<String, String> e : s.entrySet()) {
@@ -2714,6 +2687,23 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public void setMessageValidity(Connection txn, MessageId m, boolean valid)
+		throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "UPDATE messages SET valid = ? WHERE messageId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, valid ? VALIDATION_VALID : VALIDATION_INVALID);
+			ps.setBytes(2, m.getBytes());
+			int affected = ps.executeUpdate();
+			if (affected < 0) throw new DbStateException();
+			ps.close();
+		} catch (SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public void setReorderingWindow(Connection txn, ContactId c, TransportId t,
 			long rotationPeriod, long base, byte[] bitmap) throws DbException {
 		PreparedStatement ps = null;
@@ -2798,14 +2788,14 @@ abstract class JdbcDatabase implements Database<Connection> {
 			// Store the new subscriptions, if any
 			if (groups.isEmpty()) return true;
 			sql = "INSERT INTO contactGroups"
-					+ " (contactId, groupId, name, salt)"
+					+ " (contactId, groupId, clientId, descriptor)"
 					+ " VALUES (?, ?, ?, ?)";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
 			for (Group g : groups) {
 				ps.setBytes(2, g.getId().getBytes());
-				ps.setString(3, g.getName());
-				ps.setBytes(4, g.getSalt());
+				ps.setBytes(3, g.getClientId().getBytes());
+				ps.setBytes(4, g.getDescriptor());
 				ps.addBatch();
 			}
 			int[] batchAffected = ps.executeBatch();
@@ -2823,66 +2813,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void setInboxGroup(Connection txn, ContactId c, Group g)
-			throws DbException {
-		PreparedStatement ps = null;
-		try {
-			// Unset any existing inbox group for the contact
-			String sql = "UPDATE groupVisibilities"
-					+ " SET inbox = FALSE"
-					+ " WHERE contactId = ?"
-					+ " AND inbox = TRUE";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.executeUpdate();
-			int affected = ps.executeUpdate();
-			if (affected < 0 || affected > 1) throw new DbStateException();
-			ps.close();
-			// Make the group visible to the contact and set it as the inbox
-			sql = "INSERT INTO groupVisibilities"
-					+ " (contactId, groupId, inbox)"
-					+ " VALUES (?, ?, TRUE)";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setBytes(2, g.getId().getBytes());
-			affected = ps.executeUpdate();
-			if (affected != 1) throw new DbStateException();
-			ps.close();
-			// Add the group to the contact's subscriptions
-			sql = "INSERT INTO contactGroups"
-					+ " (contactId, groupId, name, salt)"
-					+ " VALUES (?, ?, ?, ?)";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setBytes(2, g.getId().getBytes());
-			ps.setString(3, g.getName());
-			ps.setBytes(4, g.getSalt());
-			affected = ps.executeUpdate();
-			if (affected != 1) throw new DbStateException();
-			ps.close();
-		} catch (SQLException e) {
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
-	public void setReadFlag(Connection txn, MessageId m, boolean read)
-			throws DbException {
-		PreparedStatement ps = null;
-		try {
-			String sql = "UPDATE messages SET read = ? WHERE messageId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setBoolean(1, read);
-			ps.setBytes(2, m.getBytes());
-			int affected = ps.executeUpdate();
-			if (affected < 0 || affected > 1) throw new DbStateException();
-			ps.close();
-		} catch (SQLException e) {
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public void setRemoteProperties(Connection txn, ContactId c,
 			Map<TransportId, TransportProperties> p) throws DbException {
 		PreparedStatement ps = null;
diff --git a/briar-core/src/org/briarproject/forum/ForumFactoryImpl.java b/briar-core/src/org/briarproject/forum/ForumFactoryImpl.java
deleted file mode 100644
index 3dcb401aa0cf1bec953f7b7e550a93ecdcf32577..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/forum/ForumFactoryImpl.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.briarproject.forum;
-
-import com.google.inject.Inject;
-
-import org.briarproject.api.forum.Forum;
-import org.briarproject.api.forum.ForumFactory;
-import org.briarproject.api.sync.GroupFactory;
-
-// Temporary facade during sync protocol refactoring
-class ForumFactoryImpl implements ForumFactory {
-
-	private final GroupFactory groupFactory;
-
-	@Inject
-	ForumFactoryImpl(GroupFactory groupFactory) {
-		this.groupFactory = groupFactory;
-	}
-
-	public Forum createForum(String name) {
-		return new ForumImpl(groupFactory.createGroup(name));
-	}
-}
diff --git a/briar-core/src/org/briarproject/forum/ForumImpl.java b/briar-core/src/org/briarproject/forum/ForumImpl.java
deleted file mode 100644
index 90a6b85abfbc6d53609935fde2c8a51ba2c8f214..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/forum/ForumImpl.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.briarproject.forum;
-
-import org.briarproject.api.forum.Forum;
-import org.briarproject.api.sync.Group;
-import org.briarproject.api.sync.GroupId;
-
-// Temporary facade during sync protocol refactoring
-class ForumImpl implements Forum {
-
-	private final Group group;
-
-	ForumImpl(Group group) {
-		this.group = group;
-	}
-
-	public GroupId getId() {
-		return group.getId();
-	}
-
-	public String getName() {
-		return group.getName();
-	}
-
-	Group getGroup() {
-		return group;
-	}
-
-	@Override
-	public int hashCode() {
-		return group.hashCode();
-	}
-
-	@Override
-	public boolean equals(Object o) {
-		return o instanceof ForumImpl && group.equals(((ForumImpl) o).group);
-	}
-}
diff --git a/briar-core/src/org/briarproject/forum/ForumManagerImpl.java b/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
index c78badda84530e6601fc691312cc2e99dc127789..ba604e93809ff82de3d26a2503268bcbe3834c62 100644
--- a/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
+++ b/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
@@ -2,79 +2,272 @@ package org.briarproject.forum;
 
 import com.google.inject.Inject;
 
+import org.briarproject.api.FormatException;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.data.BdfDictionary;
+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.data.MetadataEncoder;
+import org.briarproject.api.data.MetadataParser;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumManager;
+import org.briarproject.api.forum.ForumPost;
 import org.briarproject.api.forum.ForumPostHeader;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupFactory;
 import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.util.StringUtils;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.security.SecureRandom;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.api.forum.ForumConstants.FORUM_SALT_LENGTH;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_NAME_LENGTH;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_POST_BODY_LENGTH;
+import static org.briarproject.api.identity.Author.Status.ANONYMOUS;
+import static org.briarproject.api.identity.Author.Status.UNKNOWN;
+import static org.briarproject.api.identity.Author.Status.VERIFIED;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
 
-// Temporary facade during sync protocol refactoring
 class ForumManagerImpl implements ForumManager {
 
+	static final ClientId CLIENT_ID = new ClientId(StringUtils.fromHexString(
+			"859a7be50dca035b64bd6902fb797097"
+					+ "795af837abbf8c16d750b3c2ccc186ea"));
+
+	private static final Logger LOG =
+			Logger.getLogger(ForumManagerImpl.class.getName());
+
 	private final DatabaseComponent db;
+	private final GroupFactory groupFactory;
+	private final BdfReaderFactory bdfReaderFactory;
+	private final BdfWriterFactory bdfWriterFactory;
+	private final MetadataEncoder metadataEncoder;
+	private final MetadataParser metadataParser;
+	private final SecureRandom random;
 
 	@Inject
-	ForumManagerImpl(DatabaseComponent db) {
+	ForumManagerImpl(CryptoComponent crypto, DatabaseComponent db,
+			GroupFactory groupFactory, BdfReaderFactory bdfReaderFactory,
+			BdfWriterFactory bdfWriterFactory, MetadataEncoder metadataEncoder,
+			MetadataParser metadataParser) {
 		this.db = db;
+		this.groupFactory = groupFactory;
+		this.bdfReaderFactory = bdfReaderFactory;
+		this.bdfWriterFactory = bdfWriterFactory;
+		this.metadataEncoder = metadataEncoder;
+		this.metadataParser = metadataParser;
+		random = crypto.getSecureRandom();
+	}
+
+	@Override
+	public ClientId getClientId() {
+		return CLIENT_ID;
+	}
+
+	@Override
+	public Forum createForum(String name) {
+		int length = StringUtils.toUtf8(name).length;
+		if (length == 0) throw new IllegalArgumentException();
+		if (length > MAX_FORUM_NAME_LENGTH)
+			throw new IllegalArgumentException();
+		byte[] salt = new byte[FORUM_SALT_LENGTH];
+		random.nextBytes(salt);
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		BdfWriter w = bdfWriterFactory.createWriter(out);
+		try {
+			w.writeListStart();
+			w.writeString(name);
+			w.writeRaw(salt);
+			w.writeListEnd();
+		} catch (IOException e) {
+			// Shouldn't happen with ByteArrayOutputStream
+			throw new RuntimeException(e);
+		}
+		Group g = groupFactory.createGroup(CLIENT_ID, out.toByteArray());
+		return new Forum(g, name);
 	}
 
 	@Override
 	public boolean addForum(Forum f) throws DbException {
-		return db.addGroup(((ForumImpl) f).getGroup());
+		return db.addGroup(f.getGroup());
 	}
 
 	@Override
-	public void addLocalPost(Message m) throws DbException {
-		db.addLocalMessage(m);
+	public void addLocalPost(ForumPost p) throws DbException {
+		BdfDictionary d = new BdfDictionary();
+		d.put("timestamp", p.getMessage().getTimestamp());
+		if (p.getParent() != null) d.put("parent", p.getParent().getBytes());
+		if (p.getAuthor() != null) {
+			Author a = p.getAuthor();
+			BdfDictionary d1 = new BdfDictionary();
+			d1.put("id", a.getId().getBytes());
+			d1.put("name", a.getName());
+			d1.put("publicKey", a.getPublicKey());
+			d.put("author", d1);
+		}
+		d.put("contentType", p.getContentType());
+		d.put("local", true);
+		d.put("read", true);
+		try {
+			Metadata meta = metadataEncoder.encode(d);
+			db.addLocalMessage(p.getMessage(), CLIENT_ID, meta);
+		} catch (FormatException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
 	}
 
 	@Override
 	public Collection<Forum> getAvailableForums() throws DbException {
-		Collection<Group> groups = db.getAvailableGroups();
+		Collection<Group> groups = db.getAvailableGroups(CLIENT_ID);
 		List<Forum> forums = new ArrayList<Forum>(groups.size());
-		for (Group g : groups) forums.add(new ForumImpl(g));
+		for (Group g : groups) {
+			try {
+				forums.add(parseForum(g));
+			} catch (FormatException e) {
+				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			}
+		}
 		return Collections.unmodifiableList(forums);
 	}
 
+	private Forum parseForum(Group g) throws FormatException {
+		ByteArrayInputStream in = new ByteArrayInputStream(g.getDescriptor());
+		BdfReader r = bdfReaderFactory.createReader(in);
+		try {
+			r.readListStart();
+			String name = r.readString(MAX_FORUM_NAME_LENGTH);
+			if (name.length() == 0) throw new FormatException();
+			byte[] salt = r.readRaw(FORUM_SALT_LENGTH);
+			if (salt.length != FORUM_SALT_LENGTH) throw new FormatException();
+			r.readListEnd();
+			if (!r.eof()) throw new FormatException();
+			return new Forum(g, name);
+		} catch (FormatException e) {
+			throw e;
+		} catch (IOException e) {
+			// Shouldn't happen with ByteArrayInputStream
+			throw new RuntimeException(e);
+		}
+	}
+
 	@Override
 	public Forum getForum(GroupId g) throws DbException {
-		return new ForumImpl(db.getGroup(g));
+		Group group = db.getGroup(g);
+		if (!group.getClientId().equals(CLIENT_ID))
+			throw new IllegalArgumentException();
+		try {
+			return parseForum(group);
+		} catch (FormatException e) {
+			throw new IllegalArgumentException();
+		}
 	}
 
 	@Override
 	public Collection<Forum> getForums() throws DbException {
-		Collection<Group> groups = db.getGroups();
+		Collection<Group> groups = db.getGroups(CLIENT_ID);
 		List<Forum> forums = new ArrayList<Forum>(groups.size());
-		for (Group g : groups) forums.add(new ForumImpl(g));
+		for (Group g : groups) {
+			try {
+				forums.add(parseForum(g));
+			} catch (FormatException e) {
+				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			}
+		}
 		return Collections.unmodifiableList(forums);
 	}
 
 	@Override
 	public byte[] getPostBody(MessageId m) throws DbException {
-		return db.getMessageBody(m);
+		byte[] raw = db.getRawMessage(m);
+		ByteArrayInputStream in = new ByteArrayInputStream(raw,
+				MESSAGE_HEADER_LENGTH, raw.length - MESSAGE_HEADER_LENGTH);
+		BdfReader r = bdfReaderFactory.createReader(in);
+		try {
+			// Extract the forum post body
+			r.readListStart();
+			if (r.hasRaw()) r.skipRaw(); // Parent ID
+			else r.skipNull(); // No parent
+			if (r.hasList()) r.skipList(); // Author
+			else r.skipNull(); // No author
+			r.skipString(); // Content type
+			return r.readRaw(MAX_FORUM_POST_BODY_LENGTH);
+		} catch (FormatException e) {
+			// Not a valid forum post
+			throw new IllegalArgumentException();
+		} catch (IOException e) {
+			// Shouldn't happen with ByteArrayInputStream
+			throw new RuntimeException(e);
+		}
 	}
 
 	@Override
 	public Collection<ForumPostHeader> getPostHeaders(GroupId g)
 			throws DbException {
-		Collection<MessageHeader> headers = db.getMessageHeaders(g);
-		List<ForumPostHeader> postHeaders =
-				new ArrayList<ForumPostHeader>(headers.size());
-		for (MessageHeader m : headers)
-			postHeaders.add(new ForumPostHeaderImpl(m));
-		return Collections.unmodifiableList(postHeaders);
+		// Load the IDs of the user's own identities and contacts' identities
+		Set<AuthorId> localAuthorIds = new HashSet<AuthorId>();
+		for (LocalAuthor a : db.getLocalAuthors())
+			localAuthorIds.add(a.getId());
+		Set<AuthorId> contactAuthorIds = new HashSet<AuthorId>();
+		for (Contact c : db.getContacts())
+			contactAuthorIds.add(c.getAuthor().getId());
+		// Load and parse the metadata
+		Map<MessageId, Metadata> metadata = db.getMessageMetadata(g);
+		Collection<ForumPostHeader> headers = new ArrayList<ForumPostHeader>();
+		for (Entry<MessageId, Metadata> e : metadata.entrySet()) {
+			MessageId messageId = e.getKey();
+			Metadata meta = e.getValue();
+			try {
+				BdfDictionary d = metadataParser.parse(meta);
+				long timestamp = d.getInteger("timestamp");
+				Author author = null;
+				Author.Status authorStatus = ANONYMOUS;
+				BdfDictionary d1 = d.getDictionary("author", null);
+				if (d1 != null) {
+					AuthorId authorId = new AuthorId(d1.getRaw("id"));
+					String name = d1.getString("name");
+					byte[] publicKey = d1.getRaw("publicKey");
+					author = new Author(authorId, name, publicKey);
+					if (localAuthorIds.contains(authorId))
+						authorStatus = VERIFIED;
+					else if (contactAuthorIds.contains(authorId))
+						authorStatus = VERIFIED;
+					else authorStatus = UNKNOWN;
+				}
+				String contentType = d.getString("contentType");
+				boolean read = d.getBoolean("read");
+				headers.add(new ForumPostHeader(messageId, timestamp, author,
+						authorStatus, contentType, read));
+			} catch (FormatException ex) {
+				if (LOG.isLoggable(WARNING))
+					LOG.log(WARNING, ex.toString(), ex);
+			}
+		}
+		return headers;
 	}
 
 	@Override
@@ -89,12 +282,18 @@ class ForumManagerImpl implements ForumManager {
 
 	@Override
 	public void removeForum(Forum f) throws DbException {
-		db.removeGroup(((ForumImpl) f).getGroup());
+		db.removeGroup(f.getGroup());
 	}
 
 	@Override
 	public void setReadFlag(MessageId m, boolean read) throws DbException {
-		db.setReadFlag(m, read);
+		BdfDictionary d = new BdfDictionary();
+		d.put("read", read);
+		try {
+			db.mergeMessageMetadata(m, metadataEncoder.encode(d));
+		} catch (FormatException e) {
+			throw new RuntimeException(e);
+		}
 	}
 
 	@Override
diff --git a/briar-core/src/org/briarproject/forum/ForumModule.java b/briar-core/src/org/briarproject/forum/ForumModule.java
index 6e63a34e88bb8cee12f5261b86e2f5c3a28b6645..60ac05bbe641f3f5efda11782c3adc0668627cae 100644
--- a/briar-core/src/org/briarproject/forum/ForumModule.java
+++ b/briar-core/src/org/briarproject/forum/ForumModule.java
@@ -1,17 +1,41 @@
 package org.briarproject.forum;
 
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 
-import org.briarproject.api.forum.ForumFactory;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.data.ObjectReader;
 import org.briarproject.api.forum.ForumManager;
 import org.briarproject.api.forum.ForumPostFactory;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.sync.ValidationManager;
+import org.briarproject.api.system.Clock;
+
+import javax.inject.Singleton;
 
 public class ForumModule extends AbstractModule {
 
 	@Override
 	protected void configure() {
-		bind(ForumFactory.class).to(ForumFactoryImpl.class);
 		bind(ForumManager.class).to(ForumManagerImpl.class);
 		bind(ForumPostFactory.class).to(ForumPostFactoryImpl.class);
 	}
+
+	@Provides @Singleton
+	ForumPostValidator getValidator(LifecycleManager lifecycleManager,
+			CryptoComponent crypto, ValidationManager validationManager,
+			BdfReaderFactory bdfReaderFactory,
+			BdfWriterFactory bdfWriterFactory,
+			ObjectReader<Author> authorReader, MetadataEncoder metadataEncoder,
+			Clock clock) {
+		ForumPostValidator validator = new ForumPostValidator(crypto,
+				validationManager, bdfReaderFactory, bdfWriterFactory,
+				authorReader, metadataEncoder, clock);
+		lifecycleManager.register(validator);
+		return validator;
+	}
 }
diff --git a/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java b/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java
index b4f09ce0f25719fd44180d704562a21cb13816f4..515310aa8fed400569f11b816e972101d5a89a76 100644
--- a/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java
+++ b/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java
@@ -1,43 +1,114 @@
 package org.briarproject.forum;
 
-import com.google.inject.Inject;
-
+import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.crypto.PrivateKey;
-import org.briarproject.api.forum.Forum;
+import org.briarproject.api.crypto.Signature;
+import org.briarproject.api.data.BdfWriter;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.forum.ForumPost;
 import org.briarproject.api.forum.ForumPostFactory;
 import org.briarproject.api.identity.Author;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageFactory;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.util.StringUtils;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.security.GeneralSecurityException;
 
-// Temporary facade during sync protocol refactoring
+import javax.inject.Inject;
+
+import static org.briarproject.api.forum.ForumConstants.MAX_CONTENT_TYPE_LENGTH;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_POST_BODY_LENGTH;
+
 class ForumPostFactoryImpl implements ForumPostFactory {
 
+	private final CryptoComponent crypto;
 	private final MessageFactory messageFactory;
+	private final BdfWriterFactory bdfWriterFactory;
 
 	@Inject
-	ForumPostFactoryImpl(MessageFactory messageFactory) {
+	ForumPostFactoryImpl(CryptoComponent crypto, MessageFactory messageFactory,
+			BdfWriterFactory bdfWriterFactory) {
+		this.crypto = crypto;
 		this.messageFactory = messageFactory;
+		this.bdfWriterFactory = bdfWriterFactory;
 	}
 
 	@Override
-	public Message createAnonymousPost(MessageId parent, Forum forum,
-			String contentType, long timestamp, byte[] body)
+	public ForumPost createAnonymousPost(GroupId groupId, long timestamp,
+			MessageId parent, String contentType, byte[] body)
 			throws IOException, GeneralSecurityException {
-		return messageFactory.createAnonymousMessage(parent,
-				((ForumImpl) forum).getGroup(), contentType, timestamp, body);
+		// Validate the arguments
+		if (StringUtils.toUtf8(contentType).length > MAX_CONTENT_TYPE_LENGTH)
+			throw new IllegalArgumentException();
+		if (body.length > MAX_FORUM_POST_BODY_LENGTH)
+			throw new IllegalArgumentException();
+		// Serialise the message to a buffer
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		BdfWriter w = bdfWriterFactory.createWriter(out);
+		w.writeListStart();
+		if (parent == null) w.writeNull();
+		else w.writeRaw(parent.getBytes());
+		w.writeNull(); // No author
+		w.writeString(contentType);
+		w.writeRaw(body);
+		w.writeNull(); // No signature
+		w.writeListEnd();
+		Message m = messageFactory.createMessage(groupId, timestamp,
+				out.toByteArray());
+		return new ForumPost(m, parent, null, contentType);
 	}
 
 	@Override
-	public Message createPseudonymousPost(MessageId parent, Forum forum,
-			Author author, PrivateKey privateKey, String contentType,
-			long timestamp, byte[] body)
-			throws IOException, GeneralSecurityException {
-		return messageFactory.createPseudonymousMessage(parent,
-				((ForumImpl) forum).getGroup(), author, privateKey, contentType,
-				timestamp, body);
+	public ForumPost createPseudonymousPost(GroupId groupId, long timestamp,
+			MessageId parent, Author author, String contentType, byte[] body,
+			PrivateKey privateKey) throws IOException,
+			GeneralSecurityException {
+		// Validate the arguments
+		if (StringUtils.toUtf8(contentType).length > MAX_CONTENT_TYPE_LENGTH)
+			throw new IllegalArgumentException();
+		if (body.length > MAX_FORUM_POST_BODY_LENGTH)
+			throw new IllegalArgumentException();
+		// Serialise the data to be signed
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		BdfWriter w = bdfWriterFactory.createWriter(out);
+		w.writeListStart();
+		w.writeRaw(groupId.getBytes());
+		w.writeInteger(timestamp);
+		if (parent == null) w.writeNull();
+		else w.writeRaw(parent.getBytes());
+		writeAuthor(w, author);
+		w.writeString(contentType);
+		w.writeRaw(body);
+		w.writeListEnd();
+		// Generate the signature
+		Signature signature = crypto.getSignature();
+		signature.initSign(privateKey);
+		signature.update(out.toByteArray());
+		byte[] sig = signature.sign();
+		// Serialise the signed message
+		out.reset();
+		w = bdfWriterFactory.createWriter(out);
+		w.writeListStart();
+		if (parent == null) w.writeNull();
+		else w.writeRaw(parent.getBytes());
+		writeAuthor(w, author);
+		w.writeString(contentType);
+		w.writeRaw(body);
+		w.writeRaw(sig);
+		w.writeListEnd();
+		Message m = messageFactory.createMessage(groupId, timestamp,
+				out.toByteArray());
+		return new ForumPost(m, parent, author, contentType);
+	}
+
+	private void writeAuthor(BdfWriter w, Author a) throws IOException {
+		w.writeListStart();
+		w.writeString(a.getName());
+		w.writeRaw(a.getPublicKey());
+		w.writeListEnd();
 	}
 }
diff --git a/briar-core/src/org/briarproject/forum/ForumPostHeaderImpl.java b/briar-core/src/org/briarproject/forum/ForumPostHeaderImpl.java
deleted file mode 100644
index 5aeaa5dece8781332697668d2eae7c976402a804..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/forum/ForumPostHeaderImpl.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package org.briarproject.forum;
-
-import org.briarproject.api.forum.ForumPostHeader;
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.MessageHeader;
-import org.briarproject.api.sync.MessageId;
-
-// Temporary facade during sync protocol refactoring
-class ForumPostHeaderImpl implements ForumPostHeader {
-
-	private final MessageHeader messageHeader;
-
-	ForumPostHeaderImpl(MessageHeader messageHeader) {
-		this.messageHeader = messageHeader;
-	}
-
-	@Override
-	public MessageId getId() {
-		return messageHeader.getId();
-	}
-
-	@Override
-	public Author getAuthor() {
-		return messageHeader.getAuthor();
-	}
-
-	@Override
-	public Author.Status getAuthorStatus() {
-		return messageHeader.getAuthorStatus();
-	}
-
-	@Override
-	public String getContentType() {
-		return messageHeader.getContentType();
-	}
-
-	@Override
-	public long getTimestamp() {
-		return messageHeader.getTimestamp();
-	}
-
-	@Override
-	public boolean isRead() {
-		return messageHeader.isRead();
-	}
-}
diff --git a/briar-core/src/org/briarproject/forum/ForumPostValidator.java b/briar-core/src/org/briarproject/forum/ForumPostValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..205b290f478ca8589f0c683f9e7b56e7bb0c74eb
--- /dev/null
+++ b/briar-core/src/org/briarproject/forum/ForumPostValidator.java
@@ -0,0 +1,184 @@
+package org.briarproject.forum;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.UniqueId;
+import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyParser;
+import org.briarproject.api.crypto.PublicKey;
+import org.briarproject.api.crypto.Signature;
+import org.briarproject.api.data.BdfDictionary;
+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.data.MetadataEncoder;
+import org.briarproject.api.data.ObjectReader;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageValidator;
+import org.briarproject.api.sync.ValidationManager;
+import org.briarproject.api.system.Clock;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static org.briarproject.api.forum.ForumConstants.MAX_CONTENT_TYPE_LENGTH;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_POST_BODY_LENGTH;
+import static org.briarproject.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.briarproject.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
+import static org.briarproject.forum.ForumManagerImpl.CLIENT_ID;
+
+class ForumPostValidator implements MessageValidator {
+
+	private static final Logger LOG =
+			Logger.getLogger(ForumPostValidator.class.getName());
+
+	private final CryptoComponent crypto;
+	private final ValidationManager validationManager;
+	private final BdfReaderFactory bdfReaderFactory;
+	private final BdfWriterFactory bdfWriterFactory;
+	private final ObjectReader<Author> authorReader;
+	private final MetadataEncoder metadataEncoder;
+	private final Clock clock;
+	private final KeyParser keyParser;
+
+	@Inject
+	ForumPostValidator(CryptoComponent crypto,
+			ValidationManager validationManager,
+			BdfReaderFactory bdfReaderFactory,
+			BdfWriterFactory bdfWriterFactory,
+			ObjectReader<Author> authorReader,
+			MetadataEncoder metadataEncoder, Clock clock) {
+		this.crypto = crypto;
+		this.validationManager = validationManager;
+		this.bdfReaderFactory = bdfReaderFactory;
+		this.bdfWriterFactory = bdfWriterFactory;
+		this.authorReader = authorReader;
+		this.metadataEncoder = metadataEncoder;
+		this.clock = clock;
+		keyParser = crypto.getSignatureKeyParser();
+	}
+
+	@Override
+	public boolean start() {
+		validationManager.setMessageValidator(CLIENT_ID, this);
+		return true;
+	}
+
+	@Override
+	public boolean stop() {
+		return true;
+	}
+
+	@Override
+	public Metadata validateMessage(Message m) {
+		// Reject the message if it's too far in the future
+		long now = clock.currentTimeMillis();
+		if (m.getTimestamp() - now > MAX_CLOCK_DIFFERENCE) {
+			LOG.info("Timestamp is too far in the future");
+			return null;
+		}
+		try {
+			// Parse the message body
+			byte[] raw = m.getRaw();
+			ByteArrayInputStream in = new ByteArrayInputStream(raw,
+					MESSAGE_HEADER_LENGTH, raw.length - MESSAGE_HEADER_LENGTH);
+			BdfReader r = bdfReaderFactory.createReader(in);
+			MessageId parent = null;
+			Author author = null;
+			String contentType;
+			byte[] postBody, sig = null;
+			r.readListStart();
+			// Read the parent ID, if any
+			if (r.hasRaw()) {
+				byte[] id = r.readRaw(UniqueId.LENGTH);
+				if (id.length < UniqueId.LENGTH) throw new FormatException();
+				parent = new MessageId(id);
+			} else {
+				r.readNull();
+			}
+			// Read the author, if any
+			if (r.hasList()) author = authorReader.readObject(r);
+			else r.readNull();
+			// Read the content type
+			contentType = r.readString(MAX_CONTENT_TYPE_LENGTH);
+			// Read the forum post body
+			postBody = r.readRaw(MAX_FORUM_POST_BODY_LENGTH);
+
+			// Read the signature, if any
+			if (r.hasRaw()) sig = r.readRaw(MAX_SIGNATURE_LENGTH);
+			else r.readNull();
+			r.readListEnd();
+			if (!r.eof()) throw new FormatException();
+			// If there's an author there must be a signature and vice versa
+			if (author != null && sig == null) {
+				LOG.info("Author without signature");
+				return null;
+			}
+			if (author == null && sig != null) {
+				LOG.info("Signature without author");
+				return null;
+			}
+			// Verify the signature, if any
+			if (author != null) {
+				// Parse the public key
+				PublicKey key = keyParser.parsePublicKey(author.getPublicKey());
+				// Serialise the data to be signed
+				ByteArrayOutputStream out = new ByteArrayOutputStream();
+				BdfWriter w = bdfWriterFactory.createWriter(out);
+				w.writeListStart();
+				w.writeRaw(m.getGroupId().getBytes());
+				w.writeInteger(m.getTimestamp());
+				if (parent == null) w.writeNull();
+				else w.writeRaw(parent.getBytes());
+				writeAuthor(w, author);
+				w.writeString(contentType);
+				w.writeRaw(postBody);
+				w.writeListEnd();
+				// Verify the signature
+				Signature signature = crypto.getSignature();
+				signature.initVerify(key);
+				signature.update(out.toByteArray());
+				if (!signature.verify(sig)) {
+					LOG.info("Invalid signature");
+					return null;
+				}
+			}
+			// Return the metadata
+			BdfDictionary d = new BdfDictionary();
+			d.put("timestamp", m.getTimestamp());
+			if (parent != null) d.put("parent", parent.getBytes());
+			if (author != null) {
+				BdfDictionary d1 = new BdfDictionary();
+				d1.put("id", author.getId().getBytes());
+				d1.put("name", author.getName());
+				d1.put("publicKey", author.getPublicKey());
+				d.put("author", d1);
+			}
+			d.put("contentType", contentType);
+			d.put("read", false);
+			return metadataEncoder.encode(d);
+		} catch (IOException e) {
+			LOG.info("Invalid forum post");
+			return null;
+		} catch (GeneralSecurityException e) {
+			LOG.info("Invalid public key");
+			return null;
+		}
+	}
+
+	private void writeAuthor(BdfWriter w, Author a) throws IOException {
+		w.writeListStart();
+		w.writeString(a.getName());
+		w.writeRaw(a.getPublicKey());
+		w.writeListEnd();
+	}
+}
diff --git a/briar-core/src/org/briarproject/invitation/Connector.java b/briar-core/src/org/briarproject/invitation/Connector.java
index bd2d8a32545b5e57cb6437953cc9013fbe4e86ce..deb68a9d8ad12a7340e285b3e1c6b6bb324ba1a5 100644
--- a/briar-core/src/org/briarproject/invitation/Connector.java
+++ b/briar-core/src/org/briarproject/invitation/Connector.java
@@ -282,13 +282,13 @@ abstract class Connector extends Thread {
 		// Add the contact to the database
 		contactId = contactManager.addContact(remoteAuthor,
 				localAuthor.getId());
-		// Create a private messaging conversation
-		messagingManager.addContact(contactId, master);
 		// Store the remote transport properties
 		transportPropertyManager.setRemoteProperties(contactId, remoteProps);
 		// Derive transport keys for each transport shared with the contact
 		keyManager.addContact(contactId, remoteProps.keySet(), master,
 				timestamp, alice);
+		// Create a private messaging conversation
+		messagingManager.addContact(contactId);
 	}
 
 	protected void tryToClose(DuplexTransportConnection conn,
diff --git a/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java b/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java
index 712254c206f6039a01196c2af71bab0bc58153b0..39ba71647e09b77b985d94a39b9b0b777f1e6a7d 100644
--- a/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java
+++ b/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java
@@ -2,88 +2,205 @@ package org.briarproject.messaging;
 
 import com.google.inject.Inject;
 
+import org.briarproject.api.FormatException;
+import org.briarproject.api.UniqueId;
+import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.SecretKey;
+import org.briarproject.api.data.BdfDictionary;
+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.data.MetadataEncoder;
+import org.briarproject.api.data.MetadataParser;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.db.NoSuchContactException;
+import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.messaging.MessagingManager;
-import org.briarproject.api.messaging.PrivateConversation;
+import org.briarproject.api.messaging.PrivateMessage;
 import org.briarproject.api.messaging.PrivateMessageHeader;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupFactory;
 import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
+import org.briarproject.util.StringUtils;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
 
-// Temporary facade during sync protocol refactoring
 class MessagingManagerImpl implements MessagingManager {
 
+	static final ClientId CLIENT_ID = new ClientId(StringUtils.fromHexString(
+			"6bcdc006c0910b0f44e40644c3b31f1a"
+					+ "8bf9a6d6021d40d219c86b731b903070"));
+
+	private static final Logger LOG =
+			Logger.getLogger(MessagingManagerImpl.class.getName());
+
 	private final DatabaseComponent db;
-	private final CryptoComponent crypto;
 	private final GroupFactory groupFactory;
+	private final BdfReaderFactory bdfReaderFactory;
+	private final BdfWriterFactory bdfWriterFactory;
+	private final MetadataEncoder metadataEncoder;
+	private final MetadataParser metadataParser;
 
 	@Inject
-	MessagingManagerImpl(DatabaseComponent db, CryptoComponent crypto,
-			GroupFactory groupFactory) {
+	MessagingManagerImpl(DatabaseComponent db, GroupFactory groupFactory,
+			BdfReaderFactory bdfReaderFactory,
+			BdfWriterFactory bdfWriterFactory, MetadataEncoder metadataEncoder,
+			MetadataParser metadataParser) {
 		this.db = db;
-		this.crypto = crypto;
 		this.groupFactory = groupFactory;
+		this.bdfReaderFactory = bdfReaderFactory;
+		this.bdfWriterFactory = bdfWriterFactory;
+		this.metadataEncoder = metadataEncoder;
+		this.metadataParser = metadataParser;
 	}
 
 	@Override
-	public void addContact(ContactId c, SecretKey master) throws DbException {
-		byte[] salt = crypto.deriveGroupSalt(master);
-		Group inbox = groupFactory.createGroup("Inbox", salt);
-		db.addGroup(inbox);
-		db.setInboxGroup(c, inbox);
+	public ClientId getClientId() {
+		return CLIENT_ID;
+	}
+
+	@Override
+	public void addContact(ContactId c) throws DbException {
+		// Create the conversation group
+		Group conversation = createConversationGroup(db.getContact(c));
+		// Subscribe to the group and share it with the contact
+		db.addGroup(conversation);
+		db.addContactGroup(c, conversation);
+		db.setVisibility(conversation.getId(), Collections.singletonList(c));
+	}
+
+	private Group createConversationGroup(Contact c) {
+		AuthorId local = c.getLocalAuthorId();
+		AuthorId remote = c.getAuthor().getId();
+		byte[] descriptor = createGroupDescriptor(local, remote);
+		return groupFactory.createGroup(CLIENT_ID, descriptor);
+	}
+
+	private byte[] createGroupDescriptor(AuthorId local, AuthorId remote) {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		BdfWriter w = bdfWriterFactory.createWriter(out);
+		try {
+			w.writeListStart();
+			if (UniqueId.IdComparator.INSTANCE.compare(local, remote) < 0) {
+				w.writeRaw(local.getBytes());
+				w.writeRaw(remote.getBytes());
+			} else {
+				w.writeRaw(remote.getBytes());
+				w.writeRaw(local.getBytes());
+			}
+			w.writeListEnd();
+		} catch (IOException e) {
+			// Shouldn't happen with ByteArrayOutputStream
+			throw new RuntimeException(e);
+		}
+		return out.toByteArray();
 	}
 
 	@Override
-	public void addLocalMessage(Message m) throws DbException {
-		db.addLocalMessage(m);
+	public void addLocalMessage(PrivateMessage m) throws DbException {
+		BdfDictionary d = new BdfDictionary();
+		d.put("timestamp", m.getMessage().getTimestamp());
+		if (m.getParent() != null) d.put("parent", m.getParent().getBytes());
+		d.put("contentType", m.getContentType());
+		d.put("local", true);
+		d.put("read", true);
+		try {
+			Metadata meta = metadataEncoder.encode(d);
+			db.addLocalMessage(m.getMessage(), CLIENT_ID, meta);
+		} catch (FormatException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
 	}
 
 	@Override
-	public PrivateConversation getConversation(GroupId g) throws DbException {
-		return new PrivateConversationImpl(db.getGroup(g));
+	public ContactId getContactId(GroupId g) throws DbException {
+		// TODO: Make this more efficient
+		for (Contact c : db.getContacts()) {
+			Group conversation = createConversationGroup(c);
+			if (conversation.getId().equals(g)) return c.getId();
+		}
+		throw new NoSuchContactException();
 	}
 
 	@Override
 	public GroupId getConversationId(ContactId c) throws DbException {
-		return db.getInboxGroupId(c);
+		return createConversationGroup(db.getContact(c)).getId();
 	}
 
 	@Override
 	public Collection<PrivateMessageHeader> getMessageHeaders(ContactId c)
 			throws DbException {
-		Collection<MessageHeader> headers = db.getInboxMessageHeaders(c);
-		List<PrivateMessageHeader> privateHeaders =
-				new ArrayList<PrivateMessageHeader>(headers.size());
-		for (MessageHeader m : headers)
-			privateHeaders.add(new PrivateMessageHeaderImpl(m));
-		return Collections.unmodifiableList(privateHeaders);
+		GroupId groupId = getConversationId(c);
+		Map<MessageId, Metadata> metadata = db.getMessageMetadata(groupId);
+		Collection<MessageStatus> statuses = db.getMessageStatus(c, groupId);
+		Collection<PrivateMessageHeader> headers =
+				new ArrayList<PrivateMessageHeader>();
+		for (MessageStatus s : statuses) {
+			MessageId id = s.getMessageId();
+			Metadata m = metadata.get(id);
+			if (m == null) continue;
+			try {
+				BdfDictionary d = metadataParser.parse(m);
+				long timestamp = d.getInteger("timestamp");
+				String contentType = d.getString("contentType");
+				boolean local = d.getBoolean("local");
+				boolean read = d.getBoolean("read");
+				headers.add(new PrivateMessageHeader(id, timestamp, contentType,
+						local, read, s.isSent(), s.isSeen()));
+			} catch (FormatException e) {
+				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+			}
+		}
+		return headers;
 	}
 
 	@Override
 	public byte[] getMessageBody(MessageId m) throws DbException {
-		return db.getMessageBody(m);
-	}
-
-	@Override
-	public void setConversation(ContactId c, PrivateConversation p)
-			throws DbException {
-		db.setInboxGroup(c, ((PrivateConversationImpl) p).getGroup());
+		byte[] raw = db.getRawMessage(m);
+		ByteArrayInputStream in = new ByteArrayInputStream(raw,
+				MESSAGE_HEADER_LENGTH, raw.length - MESSAGE_HEADER_LENGTH);
+		BdfReader r = bdfReaderFactory.createReader(in);
+		try {
+			// Extract the private message body
+			r.readListStart();
+			if (r.hasRaw()) r.skipRaw(); // Parent ID
+			else r.skipNull(); // No parent
+			r.skipString(); // Content type
+			return r.readRaw(MAX_PRIVATE_MESSAGE_BODY_LENGTH);
+		} catch (FormatException e) {
+			// Not a valid private message
+			throw new IllegalArgumentException();
+		} catch (IOException e) {
+			// Shouldn't happen with ByteArrayInputStream
+			throw new RuntimeException(e);
+		}
 	}
 
 	@Override
 	public void setReadFlag(MessageId m, boolean read) throws DbException {
-		db.setReadFlag(m, read);
+		BdfDictionary d = new BdfDictionary();
+		d.put("read", read);
+		try {
+			db.mergeMessageMetadata(m, metadataEncoder.encode(d));
+		} catch (FormatException e) {
+			throw new RuntimeException(e);
+		}
 	}
 }
diff --git a/briar-core/src/org/briarproject/messaging/MessagingModule.java b/briar-core/src/org/briarproject/messaging/MessagingModule.java
index 6134939932f75da6f1049aa589a547a680ee6bef..95a28a3f130c638c3dab0af04dedd470fd8938d5 100644
--- a/briar-core/src/org/briarproject/messaging/MessagingModule.java
+++ b/briar-core/src/org/briarproject/messaging/MessagingModule.java
@@ -1,9 +1,17 @@
 package org.briarproject.messaging;
 
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.lifecycle.LifecycleManager;
 import org.briarproject.api.messaging.MessagingManager;
 import org.briarproject.api.messaging.PrivateMessageFactory;
+import org.briarproject.api.sync.ValidationManager;
+import org.briarproject.api.system.Clock;
+
+import javax.inject.Singleton;
 
 public class MessagingModule extends AbstractModule {
 
@@ -12,4 +20,15 @@ public class MessagingModule extends AbstractModule {
 		bind(MessagingManager.class).to(MessagingManagerImpl.class);
 		bind(PrivateMessageFactory.class).to(PrivateMessageFactoryImpl.class);
 	}
+
+	@Provides @Singleton
+	PrivateMessageValidator getValidator(LifecycleManager lifecycleManager,
+			ValidationManager validationManager,
+			BdfReaderFactory bdfReaderFactory, MetadataEncoder metadataEncoder,
+			Clock clock) {
+		PrivateMessageValidator validator = new PrivateMessageValidator(
+				validationManager, bdfReaderFactory, metadataEncoder, clock);
+		lifecycleManager.register(validator);
+		return validator;
+	}
 }
diff --git a/briar-core/src/org/briarproject/messaging/PrivateConversationImpl.java b/briar-core/src/org/briarproject/messaging/PrivateConversationImpl.java
deleted file mode 100644
index febb0308c76438076895c739945135ed880cf8b7..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/messaging/PrivateConversationImpl.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.briarproject.messaging;
-
-import org.briarproject.api.messaging.PrivateConversation;
-import org.briarproject.api.sync.Group;
-import org.briarproject.api.sync.GroupId;
-
-// Temporary facade during sync protocol refactoring
-class PrivateConversationImpl implements PrivateConversation {
-
-	private final Group group;
-
-	PrivateConversationImpl(Group group) {
-		this.group = group;
-	}
-
-	@Override
-	public GroupId getId() {
-		return group.getId();
-	}
-
-	Group getGroup() {
-		return group;
-	}
-
-	@Override
-	public int hashCode() {
-		return group.hashCode();
-	}
-
-	@Override
-	public boolean equals(Object o) {
-		return o instanceof PrivateConversationImpl
-				&& group.equals(((PrivateConversationImpl) o).group);
-	}
-}
diff --git a/briar-core/src/org/briarproject/messaging/PrivateMessageFactoryImpl.java b/briar-core/src/org/briarproject/messaging/PrivateMessageFactoryImpl.java
index 71098bd41a254229e71e05b1a6ad1d8862b15b77..da8112c95f8b7cb9f02fc394504cc3124e57850e 100644
--- a/briar-core/src/org/briarproject/messaging/PrivateMessageFactoryImpl.java
+++ b/briar-core/src/org/briarproject/messaging/PrivateMessageFactoryImpl.java
@@ -1,33 +1,56 @@
 package org.briarproject.messaging;
 
-import com.google.inject.Inject;
-
-import org.briarproject.api.messaging.PrivateConversation;
+import org.briarproject.api.data.BdfWriter;
+import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.messaging.PrivateMessage;
 import org.briarproject.api.messaging.PrivateMessageFactory;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageFactory;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.util.StringUtils;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.security.GeneralSecurityException;
 
-// Temporary facade during sync protocol refactoring
+import javax.inject.Inject;
+
+import static org.briarproject.api.messaging.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
+import static org.briarproject.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH;
+
 class PrivateMessageFactoryImpl implements PrivateMessageFactory {
 
 	private final MessageFactory messageFactory;
+	private final BdfWriterFactory bdfWriterFactory;
 
 	@Inject
-	PrivateMessageFactoryImpl(MessageFactory messageFactory) {
+	PrivateMessageFactoryImpl(MessageFactory messageFactory,
+			BdfWriterFactory bdfWriterFactory) {
 		this.messageFactory = messageFactory;
+		this.bdfWriterFactory = bdfWriterFactory;
 	}
 
 	@Override
-	public Message createPrivateMessage(MessageId parent,
-			PrivateConversation conversation, String contentType,
-			long timestamp, byte[] body)
+	public PrivateMessage createPrivateMessage(GroupId groupId, long timestamp,
+			MessageId parent, String contentType, byte[] body)
 			throws IOException, GeneralSecurityException {
-		return messageFactory.createAnonymousMessage(parent,
-				((PrivateConversationImpl) conversation).getGroup(),
-				contentType, timestamp, body);
+		// Validate the arguments
+		if (StringUtils.toUtf8(contentType).length > MAX_CONTENT_TYPE_LENGTH)
+			throw new IllegalArgumentException();
+		if (body.length > MAX_PRIVATE_MESSAGE_BODY_LENGTH)
+			throw new IllegalArgumentException();
+		// Serialise the message
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		BdfWriter w = bdfWriterFactory.createWriter(out);
+		w.writeListStart();
+		if (parent == null) w.writeNull();
+		else w.writeRaw(parent.getBytes());
+		w.writeString(contentType);
+		w.writeRaw(body);
+		w.writeListEnd();
+		Message m = messageFactory.createMessage(groupId, timestamp,
+				out.toByteArray());
+		return new PrivateMessage(m, parent, contentType);
 	}
 }
diff --git a/briar-core/src/org/briarproject/messaging/PrivateMessageHeaderImpl.java b/briar-core/src/org/briarproject/messaging/PrivateMessageHeaderImpl.java
deleted file mode 100644
index 377447ac81393fee8c112d2563565ec796277832..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/messaging/PrivateMessageHeaderImpl.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package org.briarproject.messaging;
-
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.messaging.PrivateMessageHeader;
-import org.briarproject.api.sync.MessageHeader;
-import org.briarproject.api.sync.MessageId;
-
-// Temporary facade during sync protocol refactoring
-public class PrivateMessageHeaderImpl implements PrivateMessageHeader {
-
-	private final MessageHeader messageHeader;
-
-	PrivateMessageHeaderImpl(MessageHeader messageHeader) {
-		this.messageHeader = messageHeader;
-	}
-
-	@Override
-	public MessageId getId() {
-		return messageHeader.getId();
-	}
-
-	@Override
-	public Author getAuthor() {
-		return messageHeader.getAuthor();
-	}
-
-	@Override
-	public String getContentType() {
-		return messageHeader.getContentType();
-	}
-
-	@Override
-	public long getTimestamp() {
-		return messageHeader.getTimestamp();
-	}
-
-	@Override
-	public boolean isLocal() {
-		return messageHeader.isLocal();
-	}
-
-	@Override
-	public boolean isRead() {
-		return messageHeader.isRead();
-	}
-
-	@Override
-	public Status getStatus() {
-		switch (messageHeader.getStatus()) {
-			case STORED:
-				return Status.STORED;
-			case SENT:
-				return Status.SENT;
-			case DELIVERED:
-				return Status.DELIVERED;
-			default:
-				throw new IllegalStateException();
-		}
-	}
-}
diff --git a/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java b/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..a3b4919380799809513fa8ee5b6e0d4572facbbd
--- /dev/null
+++ b/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java
@@ -0,0 +1,103 @@
+package org.briarproject.messaging;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.UniqueId;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfReader;
+import org.briarproject.api.data.BdfReaderFactory;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageValidator;
+import org.briarproject.api.sync.ValidationManager;
+import org.briarproject.api.system.Clock;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static org.briarproject.api.messaging.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
+import static org.briarproject.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.briarproject.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
+import static org.briarproject.messaging.MessagingManagerImpl.CLIENT_ID;
+
+class PrivateMessageValidator implements MessageValidator {
+
+	private static final Logger LOG =
+			Logger.getLogger(PrivateMessageValidator.class.getName());
+
+	private final ValidationManager validationManager;
+	private final BdfReaderFactory bdfReaderFactory;
+	private final MetadataEncoder metadataEncoder;
+	private final Clock clock;
+
+	@Inject
+	PrivateMessageValidator(ValidationManager validationManager,
+			BdfReaderFactory bdfReaderFactory, MetadataEncoder metadataEncoder,
+			Clock clock) {
+		this.validationManager = validationManager;
+		this.bdfReaderFactory = bdfReaderFactory;
+		this.metadataEncoder = metadataEncoder;
+		this.clock = clock;
+	}
+
+	@Override
+	public boolean start() {
+		validationManager.setMessageValidator(CLIENT_ID, this);
+		return true;
+	}
+
+	@Override
+	public boolean stop() {
+		return true;
+	}
+
+	@Override
+	public Metadata validateMessage(Message m) {
+		// Reject the message if it's too far in the future
+		long now = clock.currentTimeMillis();
+		if (m.getTimestamp() - now > MAX_CLOCK_DIFFERENCE) {
+			LOG.info("Timestamp is too far in the future");
+			return null;
+		}
+		try {
+			// Parse the message body
+			byte[] raw = m.getRaw();
+			ByteArrayInputStream in = new ByteArrayInputStream(raw,
+					MESSAGE_HEADER_LENGTH, raw.length - MESSAGE_HEADER_LENGTH);
+			BdfReader r = bdfReaderFactory.createReader(in);
+			MessageId parent = null;
+			String contentType;
+			r.readListStart();
+			// Read the parent ID, if any
+			if (r.hasRaw()) {
+				byte[] id = r.readRaw(UniqueId.LENGTH);
+				if (id.length < UniqueId.LENGTH) throw new FormatException();
+				parent = new MessageId(id);
+			} else {
+				r.readNull();
+			}
+			// Read the content type
+			contentType = r.readString(MAX_CONTENT_TYPE_LENGTH);
+			// Read the private message body
+			r.readRaw(MAX_PRIVATE_MESSAGE_BODY_LENGTH);
+			r.readListEnd();
+			if (!r.eof()) throw new FormatException();
+			// Return the metadata
+			BdfDictionary d = new BdfDictionary();
+			d.put("timestamp", m.getTimestamp());
+			if (parent != null) d.put("parent", parent.getBytes());
+			d.put("contentType", contentType);
+			d.put("local", false);
+			d.put("read", false);
+			return metadataEncoder.encode(d);
+		} catch (IOException e) {
+			LOG.info("Invalid private message");
+			return null;
+		}
+	}
+}
diff --git a/briar-core/src/org/briarproject/plugins/ConnectionManagerImpl.java b/briar-core/src/org/briarproject/plugins/ConnectionManagerImpl.java
index 76bf5e8c48552f8ed803f081135df2862334b7d5..eeda6bbd17aeb43623e27a55b412c466d1edd012 100644
--- a/briar-core/src/org/briarproject/plugins/ConnectionManagerImpl.java
+++ b/briar-core/src/org/briarproject/plugins/ConnectionManagerImpl.java
@@ -8,8 +8,8 @@ import org.briarproject.api.plugins.ConnectionRegistry;
 import org.briarproject.api.plugins.TransportConnectionReader;
 import org.briarproject.api.plugins.TransportConnectionWriter;
 import org.briarproject.api.plugins.duplex.DuplexTransportConnection;
-import org.briarproject.api.sync.MessagingSession;
-import org.briarproject.api.sync.MessagingSessionFactory;
+import org.briarproject.api.sync.SyncSession;
+import org.briarproject.api.sync.SyncSessionFactory;
 import org.briarproject.api.transport.KeyManager;
 import org.briarproject.api.transport.StreamContext;
 import org.briarproject.api.transport.StreamReaderFactory;
@@ -36,20 +36,20 @@ class ConnectionManagerImpl implements ConnectionManager {
 	private final KeyManager keyManager;
 	private final StreamReaderFactory streamReaderFactory;
 	private final StreamWriterFactory streamWriterFactory;
-	private final MessagingSessionFactory messagingSessionFactory;
+	private final SyncSessionFactory syncSessionFactory;
 	private final ConnectionRegistry connectionRegistry;
 
 	@Inject
 	ConnectionManagerImpl(@IoExecutor Executor ioExecutor,
 			KeyManager keyManager, StreamReaderFactory streamReaderFactory,
 			StreamWriterFactory streamWriterFactory,
-			MessagingSessionFactory messagingSessionFactory,
+			SyncSessionFactory syncSessionFactory,
 			ConnectionRegistry connectionRegistry) {
 		this.ioExecutor = ioExecutor;
 		this.keyManager = keyManager;
 		this.streamReaderFactory = streamReaderFactory;
 		this.streamWriterFactory = streamWriterFactory;
-		this.messagingSessionFactory = messagingSessionFactory;
+		this.syncSessionFactory = syncSessionFactory;
 		this.connectionRegistry = connectionRegistry;
 	}
 
@@ -87,28 +87,28 @@ class ConnectionManagerImpl implements ConnectionManager {
 		return tag;
 	}
 
-	private MessagingSession createIncomingSession(StreamContext ctx,
+	private SyncSession createIncomingSession(StreamContext ctx,
 			TransportConnectionReader r) throws IOException {
 		InputStream streamReader = streamReaderFactory.createStreamReader(
 				r.getInputStream(), ctx);
-		return messagingSessionFactory.createIncomingSession(
+		return syncSessionFactory.createIncomingSession(
 				ctx.getContactId(), ctx.getTransportId(), streamReader);
 	}
 
-	private MessagingSession createSimplexOutgoingSession(StreamContext ctx,
+	private SyncSession createSimplexOutgoingSession(StreamContext ctx,
 			TransportConnectionWriter w) throws IOException {
 		OutputStream streamWriter = streamWriterFactory.createStreamWriter(
 				w.getOutputStream(), ctx);
-		return messagingSessionFactory.createSimplexOutgoingSession(
+		return syncSessionFactory.createSimplexOutgoingSession(
 				ctx.getContactId(), ctx.getTransportId(), w.getMaxLatency(),
 				streamWriter);
 	}
 
-	private MessagingSession createDuplexOutgoingSession(StreamContext ctx,
+	private SyncSession createDuplexOutgoingSession(StreamContext ctx,
 			TransportConnectionWriter w) throws IOException {
 		OutputStream streamWriter = streamWriterFactory.createStreamWriter(
 				w.getOutputStream(), ctx);
-		return messagingSessionFactory.createDuplexOutgoingSession(
+		return syncSessionFactory.createDuplexOutgoingSession(
 				ctx.getContactId(), ctx.getTransportId(), w.getMaxLatency(),
 				w.getMaxIdleTime(), streamWriter);
 	}
@@ -214,8 +214,8 @@ class ConnectionManagerImpl implements ConnectionManager {
 		private final TransportConnectionWriter writer;
 
 		private volatile ContactId contactId = null;
-		private volatile MessagingSession incomingSession = null;
-		private volatile MessagingSession outgoingSession = null;
+		private volatile SyncSession incomingSession = null;
+		private volatile SyncSession outgoingSession = null;
 
 		private ManageIncomingDuplexConnection(TransportId transportId,
 				DuplexTransportConnection transport) {
@@ -309,8 +309,8 @@ class ConnectionManagerImpl implements ConnectionManager {
 		private final TransportConnectionReader reader;
 		private final TransportConnectionWriter writer;
 
-		private volatile MessagingSession incomingSession = null;
-		private volatile MessagingSession outgoingSession = null;
+		private volatile SyncSession incomingSession = null;
+		private volatile SyncSession outgoingSession = null;
 
 		private ManageOutgoingDuplexConnection(ContactId contactId,
 				TransportId transportId, DuplexTransportConnection transport) {
diff --git a/briar-core/src/org/briarproject/sync/AuthorFactoryImpl.java b/briar-core/src/org/briarproject/sync/AuthorFactoryImpl.java
index aad2aceca273a90560631dc89e48b2c5c3140771..bbe2674d0a9632ccbdeba9a3e6c8c36912856c57 100644
--- a/briar-core/src/org/briarproject/sync/AuthorFactoryImpl.java
+++ b/briar-core/src/org/briarproject/sync/AuthorFactoryImpl.java
@@ -1,7 +1,6 @@
 package org.briarproject.sync;
 
 import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.MessageDigest;
 import org.briarproject.api.data.BdfWriter;
 import org.briarproject.api.data.BdfWriterFactory;
 import org.briarproject.api.identity.Author;
@@ -49,10 +48,8 @@ class AuthorFactoryImpl implements AuthorFactory {
 			w.writeListEnd();
 		} catch (IOException e) {
 			// Shouldn't happen with ByteArrayOutputStream
-			throw new RuntimeException();
+			throw new RuntimeException(e);
 		}
-		MessageDigest messageDigest = crypto.getMessageDigest();
-		messageDigest.update(out.toByteArray());
-		return new AuthorId(messageDigest.digest());
+		return new AuthorId(crypto.hash(AuthorId.LABEL, out.toByteArray()));
 	}
 }
diff --git a/briar-core/src/org/briarproject/sync/AuthorReader.java b/briar-core/src/org/briarproject/sync/AuthorReader.java
index 5715bdf9c7f197db8ccae0e563af6f396fb7ea9b..8a4b85300200ef9993d3a8b04aa6cb2f0213f497 100644
--- a/briar-core/src/org/briarproject/sync/AuthorReader.java
+++ b/briar-core/src/org/briarproject/sync/AuthorReader.java
@@ -1,12 +1,10 @@
 package org.briarproject.sync;
 
 import org.briarproject.api.FormatException;
-import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.MessageDigest;
 import org.briarproject.api.data.BdfReader;
 import org.briarproject.api.data.ObjectReader;
 import org.briarproject.api.identity.Author;
-import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.AuthorFactory;
 
 import java.io.IOException;
 
@@ -15,26 +13,18 @@ import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGT
 
 class AuthorReader implements ObjectReader<Author> {
 
-	private final MessageDigest messageDigest;
+	private final AuthorFactory authorFactory;
 
-	AuthorReader(CryptoComponent crypto) {
-		messageDigest = crypto.getMessageDigest();
+	AuthorReader(AuthorFactory authorFactory) {
+		this.authorFactory = authorFactory;
 	}
 
 	public Author readObject(BdfReader r) throws IOException {
-		// Set up the reader
-		DigestingConsumer digesting = new DigestingConsumer(messageDigest);
-		r.addConsumer(digesting);
-		// Read and digest the data
 		r.readListStart();
 		String name = r.readString(MAX_AUTHOR_NAME_LENGTH);
 		if (name.length() == 0) throw new FormatException();
 		byte[] publicKey = r.readRaw(MAX_PUBLIC_KEY_LENGTH);
 		r.readListEnd();
-		// Reset the reader
-		r.removeConsumer(digesting);
-		// Build and return the author
-		AuthorId id = new AuthorId(messageDigest.digest());
-		return new Author(id, name, publicKey);
+		return authorFactory.createAuthor(name, publicKey);
 	}
 }
diff --git a/briar-core/src/org/briarproject/sync/CopyingConsumer.java b/briar-core/src/org/briarproject/sync/CopyingConsumer.java
deleted file mode 100644
index 39ee00b25fe4a10962849c175ed1941fa62c1d8d..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/CopyingConsumer.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.data.Consumer;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-
-/** A consumer that makes a copy of the bytes consumed. */
-class CopyingConsumer implements Consumer {
-
-	private final ByteArrayOutputStream out = new ByteArrayOutputStream();
-
-	public byte[] getCopy() {
-		return out.toByteArray();
-	}
-
-	public void write(byte b) throws IOException {
-		out.write(b);
-	}
-
-	public void write(byte[] b, int off, int len) throws IOException {
-		out.write(b, off, len);
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/CountingConsumer.java b/briar-core/src/org/briarproject/sync/CountingConsumer.java
deleted file mode 100644
index 63b2874e07e40ff3e98218706ab5f32c46951ef9..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/CountingConsumer.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.FormatException;
-import org.briarproject.api.data.Consumer;
-
-import java.io.IOException;
-
-/**
- * A consumer that counts the number of bytes consumed and throws a
- * FormatException if the count exceeds a given limit.
- */
-class CountingConsumer implements Consumer {
-
-	private final long limit;
-	private long count = 0;
-
-	public CountingConsumer(long limit) {
-		this.limit = limit;
-	}
-
-	public long getCount() {
-		return count;
-	}
-
-	public void write(byte b) throws IOException {
-		count++;
-		if (count > limit) throw new FormatException();
-	}
-
-	public void write(byte[] b, int off, int len) throws IOException {
-		count += len;
-		if (count > limit) throw new FormatException();
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/DigestingConsumer.java b/briar-core/src/org/briarproject/sync/DigestingConsumer.java
deleted file mode 100644
index ee217334e878dda00880a4421ce581668b710a1c..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/DigestingConsumer.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.crypto.MessageDigest;
-import org.briarproject.api.data.Consumer;
-
-/** A consumer that passes its input through a message digest. */
-class DigestingConsumer implements Consumer {
-
-	private final MessageDigest messageDigest;
-
-	public DigestingConsumer(MessageDigest messageDigest) {
-		this.messageDigest = messageDigest;
-	}
-
-	public void write(byte b) {
-		messageDigest.update(b);
-	}
-
-	public void write(byte[] b, int off, int len) {
-		messageDigest.update(b, off, len);
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/DuplexOutgoingSession.java b/briar-core/src/org/briarproject/sync/DuplexOutgoingSession.java
index 0a8751b2b7111439e0ff44efe12c67588df55499..1d14d737875f9da93fe8ea72284270addb65b994 100644
--- a/briar-core/src/org/briarproject/sync/DuplexOutgoingSession.java
+++ b/briar-core/src/org/briarproject/sync/DuplexOutgoingSession.java
@@ -10,21 +10,21 @@ import org.briarproject.api.event.EventBus;
 import org.briarproject.api.event.EventListener;
 import org.briarproject.api.event.LocalSubscriptionsUpdatedEvent;
 import org.briarproject.api.event.LocalTransportsUpdatedEvent;
-import org.briarproject.api.event.MessageAddedEvent;
 import org.briarproject.api.event.MessageRequestedEvent;
 import org.briarproject.api.event.MessageToAckEvent;
 import org.briarproject.api.event.MessageToRequestEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.event.RemoteSubscriptionsUpdatedEvent;
 import org.briarproject.api.event.RemoteTransportsUpdatedEvent;
 import org.briarproject.api.event.ShutdownEvent;
 import org.briarproject.api.event.TransportRemovedEvent;
 import org.briarproject.api.sync.Ack;
-import org.briarproject.api.sync.MessagingSession;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.PacketWriter;
 import org.briarproject.api.sync.Request;
 import org.briarproject.api.sync.SubscriptionAck;
 import org.briarproject.api.sync.SubscriptionUpdate;
+import org.briarproject.api.sync.SyncSession;
 import org.briarproject.api.sync.TransportAck;
 import org.briarproject.api.sync.TransportUpdate;
 import org.briarproject.api.system.Clock;
@@ -39,15 +39,15 @@ 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.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_PACKET_PAYLOAD_LENGTH;
 
 /**
- * An outgoing {@link org.briarproject.api.sync.MessagingSession
- * MessagingSession} suitable for duplex transports. The session offers
- * messages before sending them, keeps its output stream open when there are no
- * packets to send, and reacts to events that make packets available to send.
+ * An outgoing {@link org.briarproject.api.sync.SyncSession SyncSession}
+ * suitable for duplex transports. The session offers messages before sending
+ * them, keeps its output stream open when there are no packets to send, and
+ * reacts to events that make packets available to send.
  */
-class DuplexOutgoingSession implements MessagingSession, EventListener {
+class DuplexOutgoingSession implements SyncSession, EventListener {
 
 	// Check for retransmittable packets once every 60 seconds
 	private static final int RETX_QUERY_INTERVAL = 60 * 1000;
@@ -161,8 +161,9 @@ class DuplexOutgoingSession implements MessagingSession, EventListener {
 		if (e instanceof ContactRemovedEvent) {
 			ContactRemovedEvent c = (ContactRemovedEvent) e;
 			if (c.getContactId().equals(contactId)) interrupt();
-		} else if (e instanceof MessageAddedEvent) {
-			dbExecutor.execute(new GenerateOffer());
+		} else if (e instanceof MessageValidatedEvent) {
+			if (((MessageValidatedEvent) e).isValid())
+				dbExecutor.execute(new GenerateOffer());
 		} else if (e instanceof LocalSubscriptionsUpdatedEvent) {
 			LocalSubscriptionsUpdatedEvent l =
 					(LocalSubscriptionsUpdatedEvent) e;
@@ -243,7 +244,7 @@ class DuplexOutgoingSession implements MessagingSession, EventListener {
 			if (interrupted) return;
 			try {
 				Collection<byte[]> b = db.generateRequestedBatch(contactId,
-						MAX_PAYLOAD_LENGTH, maxLatency);
+						MAX_PACKET_PAYLOAD_LENGTH, maxLatency);
 				if (LOG.isLoggable(INFO))
 					LOG.info("Generated batch: " + (b != null));
 				if (b != null) writerTasks.add(new WriteBatch(b));
diff --git a/briar-core/src/org/briarproject/sync/GroupFactoryImpl.java b/briar-core/src/org/briarproject/sync/GroupFactoryImpl.java
index 6f36b0f30201eefca0ced9033ad4d706f52ee0f6..9c00b15cb6bdfe15fc8d17e66b280e2f92b0a67f 100644
--- a/briar-core/src/org/briarproject/sync/GroupFactoryImpl.java
+++ b/briar-core/src/org/briarproject/sync/GroupFactoryImpl.java
@@ -1,52 +1,24 @@
 package org.briarproject.sync;
 
 import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.MessageDigest;
-import org.briarproject.api.data.BdfWriter;
-import org.briarproject.api.data.BdfWriterFactory;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupFactory;
 import org.briarproject.api.sync.GroupId;
 
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-
 import javax.inject.Inject;
 
-import static org.briarproject.api.sync.MessagingConstants.GROUP_SALT_LENGTH;
-
 class GroupFactoryImpl implements GroupFactory {
 
 	private final CryptoComponent crypto;
-	private final BdfWriterFactory bdfWriterFactory;
 
 	@Inject
-	GroupFactoryImpl(CryptoComponent crypto, BdfWriterFactory bdfWriterFactory) {
+	GroupFactoryImpl(CryptoComponent crypto) {
 		this.crypto = crypto;
-		this.bdfWriterFactory = bdfWriterFactory;
-	}
-
-	public Group createGroup(String name) {
-		byte[] salt = new byte[GROUP_SALT_LENGTH];
-		crypto.getSecureRandom().nextBytes(salt);
-		return createGroup(name, salt);
 	}
 
-	public Group createGroup(String name, byte[] salt) {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		try {
-			w.writeListStart();
-			w.writeString(name);
-			w.writeRaw(salt);
-			w.writeListEnd();
-		} catch (IOException e) {
-			// Shouldn't happen with ByteArrayOutputStream
-			throw new RuntimeException();
-		}
-		MessageDigest messageDigest = crypto.getMessageDigest();
-		messageDigest.update(out.toByteArray());
-		GroupId id = new GroupId(messageDigest.digest());
-		return new Group(id, name, salt);
+	public Group createGroup(ClientId c, byte[] descriptor) {
+		byte[] hash = crypto.hash(GroupId.LABEL, c.getBytes(), descriptor);
+		return new Group(new GroupId(hash), c, descriptor);
 	}
 }
diff --git a/briar-core/src/org/briarproject/sync/GroupReader.java b/briar-core/src/org/briarproject/sync/GroupReader.java
index 529a8170a7cdd01be8228e5cbd5ed358bbdd44c2..2500c8b1c968ed69d628fcdc0d4f6575fe90b4ec 100644
--- a/briar-core/src/org/briarproject/sync/GroupReader.java
+++ b/briar-core/src/org/briarproject/sync/GroupReader.java
@@ -1,39 +1,31 @@
 package org.briarproject.sync;
 
 import org.briarproject.api.FormatException;
-import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.MessageDigest;
+import org.briarproject.api.UniqueId;
 import org.briarproject.api.data.BdfReader;
 import org.briarproject.api.data.ObjectReader;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
-import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.GroupFactory;
 
 import java.io.IOException;
 
-import static org.briarproject.api.sync.MessagingConstants.GROUP_SALT_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_GROUP_NAME_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
 
 class GroupReader implements ObjectReader<Group> {
 
-	private final MessageDigest messageDigest;
+	private final GroupFactory groupFactory;
 
-	GroupReader(CryptoComponent crypto) {
-		messageDigest = crypto.getMessageDigest();
+	GroupReader(GroupFactory groupFactory) {
+		this.groupFactory = groupFactory;
 	}
 
 	public Group readObject(BdfReader r) throws IOException {
-		DigestingConsumer digesting = new DigestingConsumer(messageDigest);
-		// Read and digest the data
-		r.addConsumer(digesting);
 		r.readListStart();
-		String name = r.readString(MAX_GROUP_NAME_LENGTH);
-		if (name.length() == 0) throw new FormatException();
-		byte[] salt = r.readRaw(GROUP_SALT_LENGTH);
-		if (salt.length != GROUP_SALT_LENGTH) throw new FormatException();
+		byte[] id = r.readRaw(UniqueId.LENGTH);
+		if (id.length != UniqueId.LENGTH) throw new FormatException();
+		byte[] descriptor = r.readRaw(MAX_GROUP_DESCRIPTOR_LENGTH);
 		r.readListEnd();
-		r.removeConsumer(digesting);
-		// Build and return the group
-		GroupId id = new GroupId(messageDigest.digest());
-		return new Group(id, name, salt);
+		return groupFactory.createGroup(new ClientId(id), descriptor);
 	}
 }
diff --git a/briar-core/src/org/briarproject/sync/IncomingSession.java b/briar-core/src/org/briarproject/sync/IncomingSession.java
index 907ffaf195ddad2265c346fac4e972d56c5b953a..9e8bd2caa95e385b2c761858947b8d168e7cedcd 100644
--- a/briar-core/src/org/briarproject/sync/IncomingSession.java
+++ b/briar-core/src/org/briarproject/sync/IncomingSession.java
@@ -13,37 +13,30 @@ import org.briarproject.api.event.ShutdownEvent;
 import org.briarproject.api.event.TransportRemovedEvent;
 import org.briarproject.api.sync.Ack;
 import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageVerifier;
-import org.briarproject.api.sync.MessagingSession;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.PacketReader;
 import org.briarproject.api.sync.Request;
 import org.briarproject.api.sync.SubscriptionAck;
 import org.briarproject.api.sync.SubscriptionUpdate;
+import org.briarproject.api.sync.SyncSession;
 import org.briarproject.api.sync.TransportAck;
 import org.briarproject.api.sync.TransportUpdate;
-import org.briarproject.api.sync.UnverifiedMessage;
 
 import java.io.IOException;
-import java.security.GeneralSecurityException;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
 import static java.util.logging.Level.WARNING;
 
-/**
- * An incoming {@link org.briarproject.api.sync.MessagingSession
- * MessagingSession}.
- */
-class IncomingSession implements MessagingSession, EventListener {
+/** An incoming {@link org.briarproject.api.sync.SyncSession SyncSession}. */
+class IncomingSession implements SyncSession, EventListener {
 
 	private static final Logger LOG =
 			Logger.getLogger(IncomingSession.class.getName());
 
 	private final DatabaseComponent db;
-	private final Executor dbExecutor, cryptoExecutor;
+	private final Executor dbExecutor;
 	private final EventBus eventBus;
-	private final MessageVerifier messageVerifier;
 	private final ContactId contactId;
 	private final TransportId transportId;
 	private final PacketReader packetReader;
@@ -51,14 +44,11 @@ class IncomingSession implements MessagingSession, EventListener {
 	private volatile boolean interrupted = false;
 
 	IncomingSession(DatabaseComponent db, Executor dbExecutor,
-			Executor cryptoExecutor, EventBus eventBus,
-			MessageVerifier messageVerifier, ContactId contactId,
-			TransportId transportId, PacketReader packetReader) {
+			EventBus eventBus, ContactId contactId, TransportId transportId,
+			PacketReader packetReader) {
 		this.db = db;
 		this.dbExecutor = dbExecutor;
-		this.cryptoExecutor = cryptoExecutor;
 		this.eventBus = eventBus;
-		this.messageVerifier = messageVerifier;
 		this.contactId = contactId;
 		this.transportId = transportId;
 		this.packetReader = packetReader;
@@ -73,8 +63,8 @@ class IncomingSession implements MessagingSession, EventListener {
 					Ack a = packetReader.readAck();
 					dbExecutor.execute(new ReceiveAck(a));
 				} else if (packetReader.hasMessage()) {
-					UnverifiedMessage m = packetReader.readMessage();
-					cryptoExecutor.execute(new VerifyMessage(m));
+					Message m = packetReader.readMessage();
+					dbExecutor.execute(new ReceiveMessage(m));
 				} else if (packetReader.hasOffer()) {
 					Offer o = packetReader.readOffer();
 					dbExecutor.execute(new ReceiveOffer(o));
@@ -137,25 +127,6 @@ class IncomingSession implements MessagingSession, EventListener {
 		}
 	}
 
-	private class VerifyMessage implements Runnable {
-
-		private final UnverifiedMessage message;
-
-		private VerifyMessage(UnverifiedMessage message) {
-			this.message = message;
-		}
-
-		public void run() {
-			try {
-				Message m = messageVerifier.verifyMessage(message);
-				dbExecutor.execute(new ReceiveMessage(m));
-			} catch (GeneralSecurityException e) {
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-				interrupt();
-			}
-		}
-	}
-
 	private class ReceiveMessage implements Runnable {
 
 		private final Message message;
diff --git a/briar-core/src/org/briarproject/sync/MessageFactoryImpl.java b/briar-core/src/org/briarproject/sync/MessageFactoryImpl.java
index 493713e3bd8366bda1547837cb3d6d053b8e4b67..e32ec110f31602461b2dc1de352b8660139e18c5 100644
--- a/briar-core/src/org/briarproject/sync/MessageFactoryImpl.java
+++ b/briar-core/src/org/briarproject/sync/MessageFactoryImpl.java
@@ -1,129 +1,39 @@
 package org.briarproject.sync;
 
+import com.google.inject.Inject;
+
+import org.briarproject.api.UniqueId;
 import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.MessageDigest;
-import org.briarproject.api.crypto.PrivateKey;
-import org.briarproject.api.crypto.Signature;
-import org.briarproject.api.data.BdfWriter;
-import org.briarproject.api.data.BdfWriterFactory;
-import org.briarproject.api.data.Consumer;
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageFactory;
 import org.briarproject.api.sync.MessageId;
-import org.briarproject.util.StringUtils;
+import org.briarproject.util.ByteUtils;
 
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.security.SecureRandom;
-
-import javax.inject.Inject;
 
-import static org.briarproject.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_BODY_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MESSAGE_SALT_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_MESSAGE_BODY_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
 
 class MessageFactoryImpl implements MessageFactory {
 
-	private final Signature signature;
-	private final SecureRandom random;
-	private final MessageDigest messageDigest;
-	private final BdfWriterFactory bdfWriterFactory;
+	private final CryptoComponent crypto;
 
 	@Inject
-	MessageFactoryImpl(CryptoComponent crypto, BdfWriterFactory bdfWriterFactory) {
-		signature = crypto.getSignature();
-		random = crypto.getSecureRandom();
-		messageDigest = crypto.getMessageDigest();
-		this.bdfWriterFactory = bdfWriterFactory;
-	}
-
-	public Message createAnonymousMessage(MessageId parent, Group group,
-			String contentType, long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException {
-		return createMessage(parent, group, null, null, contentType, timestamp,
-				body);
-	}
-
-	public Message createPseudonymousMessage(MessageId parent, Group group,
-			Author author, PrivateKey privateKey, String contentType,
-			long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException {
-		return createMessage(parent, group, author, privateKey, contentType,
-				timestamp, body);
+	MessageFactoryImpl(CryptoComponent crypto) {
+		this.crypto = crypto;
 	}
 
-	private Message createMessage(MessageId parent, Group group, Author author,
-			PrivateKey privateKey, String contentType, long timestamp,
-			byte[] body) throws IOException, GeneralSecurityException {
-		// Validate the arguments
-		if ((author == null) != (privateKey == null))
+	@Override
+	public Message createMessage(GroupId groupId, long timestamp, byte[] body)
+			throws IOException {
+		if (body.length > MAX_MESSAGE_BODY_LENGTH)
 			throw new IllegalArgumentException();
-		if (StringUtils.toUtf8(contentType).length > MAX_CONTENT_TYPE_LENGTH)
-			throw new IllegalArgumentException();
-		if (body.length > MAX_BODY_LENGTH)
-			throw new IllegalArgumentException();
-		// Serialise the message to a buffer
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		// Initialise the consumers
-		CountingConsumer counting = new CountingConsumer(MAX_PAYLOAD_LENGTH);
-		w.addConsumer(counting);
-		Consumer digestingConsumer = new DigestingConsumer(messageDigest);
-		w.addConsumer(digestingConsumer);
-		Consumer signingConsumer = null;
-		if (privateKey != null) {
-			signature.initSign(privateKey);
-			signingConsumer = new SigningConsumer(signature);
-			w.addConsumer(signingConsumer);
-		}
-		// Write the message
-		w.writeListStart();
-		if (parent == null) w.writeNull();
-		else w.writeRaw(parent.getBytes());
-		writeGroup(w, group);
-		if (author == null) w.writeNull();
-		else writeAuthor(w, author);
-		w.writeString(contentType);
-		w.writeInteger(timestamp);
-		byte[] salt = new byte[MESSAGE_SALT_LENGTH];
-		random.nextBytes(salt);
-		w.writeRaw(salt);
-		w.writeRaw(body);
-		int bodyStart = (int) counting.getCount() - body.length;
-		// Sign the message with the author's private key, if there is one
-		if (privateKey == null) {
-			w.writeNull();
-		} else {
-			w.removeConsumer(signingConsumer);
-			byte[] sig = signature.sign();
-			if (sig.length > MAX_SIGNATURE_LENGTH)
-				throw new IllegalArgumentException();
-			w.writeRaw(sig);
-		}
-		w.writeListEnd();
-		// Hash the message, including the signature, to get the message ID
-		w.removeConsumer(digestingConsumer);
-		MessageId id = new MessageId(messageDigest.digest());
-		return new MessageImpl(id, parent, group, author, contentType,
-				timestamp, out.toByteArray(), bodyStart, body.length);
-	}
-
-	private void writeGroup(BdfWriter w, Group g) throws IOException {
-		w.writeListStart();
-		w.writeString(g.getName());
-		w.writeRaw(g.getSalt());
-		w.writeListEnd();
-	}
-
-	private void writeAuthor(BdfWriter w, Author a) throws IOException {
-		w.writeListStart();
-		w.writeString(a.getName());
-		w.writeRaw(a.getPublicKey());
-		w.writeListEnd();
+		byte[] raw = new byte[MESSAGE_HEADER_LENGTH + body.length];
+		System.arraycopy(groupId.getBytes(), 0, raw, 0, UniqueId.LENGTH);
+		ByteUtils.writeUint64(timestamp, raw, UniqueId.LENGTH);
+		System.arraycopy(body, 0, raw, MESSAGE_HEADER_LENGTH, body.length);
+		MessageId id = new MessageId(crypto.hash(MessageId.LABEL, raw));
+		return new Message(id, groupId, timestamp, raw);
 	}
 }
diff --git a/briar-core/src/org/briarproject/sync/MessageImpl.java b/briar-core/src/org/briarproject/sync/MessageImpl.java
deleted file mode 100644
index dc2f7af170e07f710739831bbe21ae3565b874d0..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/MessageImpl.java
+++ /dev/null
@@ -1,84 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.Group;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageId;
-
-import static org.briarproject.api.sync.MessagingConstants.MAX_BODY_LENGTH;
-
-/** A simple in-memory implementation of a message. */
-class MessageImpl implements Message {
-
-	private final MessageId id, parent;
-	private final Group group;
-	private final Author author;
-	private final String contentType;
-	private final long timestamp;
-	private final byte[] raw;
-	private final int bodyStart, bodyLength;
-
-	public MessageImpl(MessageId id, MessageId parent, Group group,
-			Author author, String contentType, long timestamp,
-			byte[] raw, int bodyStart, int bodyLength) {
-		if (bodyStart + bodyLength > raw.length)
-			throw new IllegalArgumentException();
-		if (bodyLength > MAX_BODY_LENGTH)
-			throw new IllegalArgumentException();
-		this.id = id;
-		this.parent = parent;
-		this.group = group;
-		this.author = author;
-		this.contentType = contentType;
-		this.timestamp = timestamp;
-		this.raw = raw;
-		this.bodyStart = bodyStart;
-		this.bodyLength = bodyLength;
-	}
-
-	public MessageId getId() {
-		return id;
-	}
-
-	public MessageId getParent() {
-		return parent;
-	}
-
-	public Group getGroup() {
-		return group;
-	}
-
-	public Author getAuthor() {
-		return author;
-	}
-
-	public String getContentType() {
-		return contentType;
-	}
-
-	public long getTimestamp() {
-		return timestamp;
-	}
-
-	public byte[] getSerialised() {
-		return raw;
-	}
-
-	public int getBodyStart() {
-		return bodyStart;
-	}
-
-	public int getBodyLength() {
-		return bodyLength;
-	}
-
-	@Override
-	public int hashCode() {
-		return id.hashCode();
-	}
-
-	@Override
-	public boolean equals(Object o) {
-		return o instanceof Message && id.equals(((Message) o).getId());
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/MessageReader.java b/briar-core/src/org/briarproject/sync/MessageReader.java
deleted file mode 100644
index 617d9fa68621ea07e90da88ea01b5e4fd2bd1979..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/MessageReader.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.FormatException;
-import org.briarproject.api.UniqueId;
-import org.briarproject.api.data.BdfReader;
-import org.briarproject.api.data.ObjectReader;
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.Group;
-import org.briarproject.api.sync.MessageId;
-import org.briarproject.api.sync.UnverifiedMessage;
-
-import java.io.IOException;
-
-import static org.briarproject.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_BODY_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MESSAGE_SALT_LENGTH;
-
-class MessageReader implements ObjectReader<UnverifiedMessage> {
-
-	private final ObjectReader<Group> groupReader;
-	private final ObjectReader<Author> authorReader;
-
-	MessageReader(ObjectReader<Group> groupReader,
-			ObjectReader<Author> authorReader) {
-		this.groupReader = groupReader;
-		this.authorReader = authorReader;
-	}
-
-	public UnverifiedMessage readObject(BdfReader r) throws IOException {
-		CopyingConsumer copying = new CopyingConsumer();
-		CountingConsumer counting = new CountingConsumer(MAX_PAYLOAD_LENGTH);
-		r.addConsumer(copying);
-		r.addConsumer(counting);
-		// Read the start of the message
-		r.readListStart();
-		// Read the parent's message ID, if there is one
-		MessageId parent = null;
-		if (r.hasNull()) {
-			r.readNull();
-		} else {
-			byte[] b = r.readRaw(UniqueId.LENGTH);
-			if (b.length < UniqueId.LENGTH) throw new FormatException();
-			parent = new MessageId(b);
-		}
-		// Read the group
-		Group group = groupReader.readObject(r);
-		// Read the author, if there is one
-		Author author = null;
-		if (r.hasNull()) r.readNull();
-		else author = authorReader.readObject(r);
-		// Read the content type
-		String contentType = r.readString(MAX_CONTENT_TYPE_LENGTH);
-		// Read the timestamp
-		long timestamp = r.readInteger();
-		if (timestamp < 0) throw new FormatException();
-		// Read the salt
-		byte[] salt = r.readRaw(MESSAGE_SALT_LENGTH);
-		if (salt.length < MESSAGE_SALT_LENGTH) throw new FormatException();
-		// Read the message body
-		byte[] body = r.readRaw(MAX_BODY_LENGTH);
-		// Record the offset of the body within the message
-		int bodyStart = (int) counting.getCount() - body.length;
-		// Record the length of the data covered by the author's signature
-		int signedLength = (int) counting.getCount();
-		// Read the author's signature, if there is one
-		byte[] signature = null;
-		if (author == null) r.readNull();
-		else signature = r.readRaw(MAX_SIGNATURE_LENGTH);
-		// Read the end of the message
-		r.readListEnd();
-		// Reset the reader
-		r.removeConsumer(counting);
-		r.removeConsumer(copying);
-		// Build and return the unverified message
-		byte[] raw = copying.getCopy();
-		return new UnverifiedMessage(parent, group, author, contentType,
-				timestamp, raw, signature, bodyStart, body.length,
-				signedLength);
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/MessageVerifierImpl.java b/briar-core/src/org/briarproject/sync/MessageVerifierImpl.java
deleted file mode 100644
index 13fc03abb5aca1c7f4c1a2ebc227706229132a16..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/MessageVerifierImpl.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.KeyParser;
-import org.briarproject.api.crypto.MessageDigest;
-import org.briarproject.api.crypto.PublicKey;
-import org.briarproject.api.crypto.Signature;
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageId;
-import org.briarproject.api.sync.MessageVerifier;
-import org.briarproject.api.sync.UnverifiedMessage;
-import org.briarproject.api.system.Clock;
-
-import java.security.GeneralSecurityException;
-import java.util.logging.Logger;
-
-import javax.inject.Inject;
-
-import static java.util.logging.Level.INFO;
-import static org.briarproject.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
-
-class MessageVerifierImpl implements MessageVerifier {
-
-	private static final Logger LOG =
-			Logger.getLogger(MessageVerifierImpl.class.getName());
-
-	private final CryptoComponent crypto;
-	private final Clock clock;
-	private final KeyParser keyParser;
-
-	@Inject
-	MessageVerifierImpl(CryptoComponent crypto, Clock clock) {
-		this.crypto = crypto;
-		this.clock = clock;
-		keyParser = crypto.getSignatureKeyParser();
-	}
-
-	public Message verifyMessage(UnverifiedMessage m)
-			throws GeneralSecurityException {
-		long now = System.currentTimeMillis();
-		MessageDigest messageDigest = crypto.getMessageDigest();
-		Signature signature = crypto.getSignature();
-		// Reject the message if it's too far in the future
-		if (m.getTimestamp() > clock.currentTimeMillis() + MAX_CLOCK_DIFFERENCE)
-			throw new GeneralSecurityException();
-		// Hash the message to get the message ID
-		byte[] raw = m.getSerialised();
-		messageDigest.update(raw);
-		MessageId id = new MessageId(messageDigest.digest());
-		// Verify the author's signature, if there is one
-		Author author = m.getAuthor();
-		if (author != null) {
-			PublicKey k = keyParser.parsePublicKey(author.getPublicKey());
-			signature.initVerify(k);
-			signature.update(raw, 0, m.getSignedLength());
-			if (!signature.verify(m.getSignature()))
-				throw new GeneralSecurityException();
-		}
-		Message verified = new MessageImpl(id, m.getParent(), m.getGroup(),
-				author, m.getContentType(), m.getTimestamp(), raw,
-				m.getBodyStart(), m.getBodyLength());
-		long duration = System.currentTimeMillis() - now;
-		if (LOG.isLoggable(INFO))
-			LOG.info("Verifying message took " + duration + " ms");
-		return verified;
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/PacketReaderFactoryImpl.java b/briar-core/src/org/briarproject/sync/PacketReaderFactoryImpl.java
index ed37e602c6fb2fdf3f1c7224507473f03b988510..eac4b931f9906d6e3a34cc105ea16713ac1a1148 100644
--- a/briar-core/src/org/briarproject/sync/PacketReaderFactoryImpl.java
+++ b/briar-core/src/org/briarproject/sync/PacketReaderFactoryImpl.java
@@ -1,11 +1,11 @@
 package org.briarproject.sync;
 
+import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.data.BdfReaderFactory;
 import org.briarproject.api.data.ObjectReader;
 import org.briarproject.api.sync.PacketReader;
 import org.briarproject.api.sync.PacketReaderFactory;
 import org.briarproject.api.sync.SubscriptionUpdate;
-import org.briarproject.api.sync.UnverifiedMessage;
 
 import java.io.InputStream;
 
@@ -13,21 +13,21 @@ import javax.inject.Inject;
 
 class PacketReaderFactoryImpl implements PacketReaderFactory {
 
+	private final CryptoComponent crypto;
 	private final BdfReaderFactory bdfReaderFactory;
-	private final ObjectReader<UnverifiedMessage> messageReader;
 	private final ObjectReader<SubscriptionUpdate> subscriptionUpdateReader;
 
 	@Inject
-	PacketReaderFactoryImpl(BdfReaderFactory bdfReaderFactory,
-			ObjectReader<UnverifiedMessage> messageReader,
+	PacketReaderFactoryImpl(CryptoComponent crypto,
+			BdfReaderFactory bdfReaderFactory,
 			ObjectReader<SubscriptionUpdate> subscriptionUpdateReader) {
+		this.crypto = crypto;
 		this.bdfReaderFactory = bdfReaderFactory;
-		this.messageReader = messageReader;
 		this.subscriptionUpdateReader = subscriptionUpdateReader;
 	}
 
 	public PacketReader createPacketReader(InputStream in) {
-		return new PacketReaderImpl(bdfReaderFactory, messageReader,
+		return new PacketReaderImpl(crypto, bdfReaderFactory,
 				subscriptionUpdateReader, in);
 	}
 }
diff --git a/briar-core/src/org/briarproject/sync/PacketReaderImpl.java b/briar-core/src/org/briarproject/sync/PacketReaderImpl.java
index 08a6632abe60168656f3178f7776c977ab0b090c..2d1a8bfc7ce4f27ffe76bf69c15c57fc822f99e7 100644
--- a/briar-core/src/org/briarproject/sync/PacketReaderImpl.java
+++ b/briar-core/src/org/briarproject/sync/PacketReaderImpl.java
@@ -4,10 +4,13 @@ import org.briarproject.api.FormatException;
 import org.briarproject.api.TransportId;
 import org.briarproject.api.TransportProperties;
 import org.briarproject.api.UniqueId;
+import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.data.BdfReader;
 import org.briarproject.api.data.BdfReaderFactory;
 import org.briarproject.api.data.ObjectReader;
 import org.briarproject.api.sync.Ack;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.PacketReader;
@@ -16,7 +19,6 @@ import org.briarproject.api.sync.SubscriptionAck;
 import org.briarproject.api.sync.SubscriptionUpdate;
 import org.briarproject.api.sync.TransportAck;
 import org.briarproject.api.sync.TransportUpdate;
-import org.briarproject.api.sync.UnverifiedMessage;
 import org.briarproject.util.ByteUtils;
 
 import java.io.ByteArrayInputStream;
@@ -31,9 +33,6 @@ import java.util.Map;
 import static org.briarproject.api.TransportPropertyConstants.MAX_PROPERTIES_PER_TRANSPORT;
 import static org.briarproject.api.TransportPropertyConstants.MAX_PROPERTY_LENGTH;
 import static org.briarproject.api.TransportPropertyConstants.MAX_TRANSPORT_ID_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.HEADER_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.PROTOCOL_VERSION;
 import static org.briarproject.api.sync.PacketTypes.ACK;
 import static org.briarproject.api.sync.PacketTypes.MESSAGE;
 import static org.briarproject.api.sync.PacketTypes.OFFER;
@@ -42,14 +41,18 @@ import static org.briarproject.api.sync.PacketTypes.SUBSCRIPTION_ACK;
 import static org.briarproject.api.sync.PacketTypes.SUBSCRIPTION_UPDATE;
 import static org.briarproject.api.sync.PacketTypes.TRANSPORT_ACK;
 import static org.briarproject.api.sync.PacketTypes.TRANSPORT_UPDATE;
+import static org.briarproject.api.sync.SyncConstants.MAX_PACKET_PAYLOAD_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.PACKET_HEADER_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.PROTOCOL_VERSION;
 
 // This class is not thread-safe
 class PacketReaderImpl implements PacketReader {
 
 	private enum State { BUFFER_EMPTY, BUFFER_FULL, EOF }
 
+	private final CryptoComponent crypto;
 	private final BdfReaderFactory bdfReaderFactory;
-	private final ObjectReader<UnverifiedMessage> messageReader;
 	private final ObjectReader<SubscriptionUpdate> subscriptionUpdateReader;
 	private final InputStream in;
 	private final byte[] header, payload;
@@ -57,24 +60,23 @@ class PacketReaderImpl implements PacketReader {
 	private State state = State.BUFFER_EMPTY;
 	private int payloadLength = 0;
 
-	PacketReaderImpl(BdfReaderFactory bdfReaderFactory,
-			ObjectReader<UnverifiedMessage> messageReader,
+	PacketReaderImpl(CryptoComponent crypto, BdfReaderFactory bdfReaderFactory,
 			ObjectReader<SubscriptionUpdate> subscriptionUpdateReader,
 			InputStream in) {
+		this.crypto = crypto;
 		this.bdfReaderFactory = bdfReaderFactory;
-		this.messageReader = messageReader;
 		this.subscriptionUpdateReader = subscriptionUpdateReader;
 		this.in = in;
-		header = new byte[HEADER_LENGTH];
-		payload = new byte[MAX_PAYLOAD_LENGTH];
+		header = new byte[PACKET_HEADER_LENGTH];
+		payload = new byte[MAX_PACKET_PAYLOAD_LENGTH];
 	}
 
 	private void readPacket() throws IOException {
-		assert state == State.BUFFER_EMPTY;
+		if (state != State.BUFFER_EMPTY) throw new IllegalStateException();
 		// Read the header
 		int offset = 0;
-		while (offset < HEADER_LENGTH) {
-			int read = in.read(header, offset, HEADER_LENGTH - offset);
+		while (offset < PACKET_HEADER_LENGTH) {
+			int read = in.read(header, offset, PACKET_HEADER_LENGTH - offset);
 			if (read == -1) {
 				if (offset > 0) throw new FormatException();
 				state = State.EOF;
@@ -86,7 +88,7 @@ class PacketReaderImpl implements PacketReader {
 		if (header[0] != PROTOCOL_VERSION) throw new FormatException();
 		// Read the payload length
 		payloadLength = ByteUtils.readUint16(header, 2);
-		if (payloadLength > MAX_PAYLOAD_LENGTH) throw new FormatException();
+		if (payloadLength > MAX_PACKET_PAYLOAD_LENGTH) throw new FormatException();
 		// Read the payload
 		offset = 0;
 		while (offset < payloadLength) {
@@ -99,7 +101,7 @@ class PacketReaderImpl implements PacketReader {
 
 	public boolean eof() throws IOException {
 		if (state == State.BUFFER_EMPTY) readPacket();
-		assert state != State.BUFFER_EMPTY;
+		if (state == State.BUFFER_EMPTY) throw new IllegalStateException();
 		return state == State.EOF;
 	}
 
@@ -109,44 +111,43 @@ class PacketReaderImpl implements PacketReader {
 
 	public Ack readAck() throws IOException {
 		if (!hasAck()) throw new FormatException();
-		// Set up the reader
-		InputStream bais = new ByteArrayInputStream(payload, 0, payloadLength);
-		BdfReader r = bdfReaderFactory.createReader(bais);
-		// Read the start of the payload
-		r.readListStart();
-		// Read the message IDs
-		List<MessageId> acked = new ArrayList<MessageId>();
-		r.readListStart();
-		while (!r.hasListEnd()) {
-			byte[] b = r.readRaw(UniqueId.LENGTH);
-			if (b.length != UniqueId.LENGTH)
-				throw new FormatException();
-			acked.add(new MessageId(b));
+		return new Ack(Collections.unmodifiableList(readMessageIds()));
+	}
+
+	private List<MessageId> readMessageIds() throws IOException {
+		if (payloadLength == 0) throw new FormatException();
+		if (payloadLength % UniqueId.LENGTH != 0) throw new FormatException();
+		List<MessageId> ids = new ArrayList<MessageId>();
+		for (int off = 0; off < payloadLength; off += UniqueId.LENGTH) {
+			byte[] id = new byte[UniqueId.LENGTH];
+			System.arraycopy(payload, off, id, 0, UniqueId.LENGTH);
+			ids.add(new MessageId(id));
 		}
-		if (acked.isEmpty()) throw new FormatException();
-		r.readListEnd();
-		// Read the end of the payload
-		r.readListEnd();
-		if (!r.eof()) throw new FormatException();
 		state = State.BUFFER_EMPTY;
-		// Build and return the ack
-		return new Ack(Collections.unmodifiableList(acked));
+		return ids;
 	}
 
 	public boolean hasMessage() throws IOException {
 		return !eof() && header[1] == MESSAGE;
 	}
 
-	public UnverifiedMessage readMessage() throws IOException {
+	public Message readMessage() throws IOException {
 		if (!hasMessage()) throw new FormatException();
-		// Set up the reader
-		InputStream bais = new ByteArrayInputStream(payload, 0, payloadLength);
-		BdfReader r = bdfReaderFactory.createReader(bais);
-		// Read and build the message
-		UnverifiedMessage m = messageReader.readObject(r);
-		if (!r.eof()) throw new FormatException();
+		if (payloadLength <= MESSAGE_HEADER_LENGTH) throw new FormatException();
+		// Group ID
+		byte[] id = new byte[UniqueId.LENGTH];
+		System.arraycopy(payload, 0, id, 0, UniqueId.LENGTH);
+		GroupId groupId = new GroupId(id);
+		// Timestamp
+		long timestamp = ByteUtils.readUint64(payload, UniqueId.LENGTH);
+		if (timestamp < 0) throw new FormatException();
+		// Raw message
+		byte[] raw = new byte[payloadLength];
+		System.arraycopy(payload, 0, raw, 0, payloadLength);
 		state = State.BUFFER_EMPTY;
-		return m;
+		// Message ID
+		MessageId messageId = new MessageId(crypto.hash(MessageId.LABEL, raw));
+		return new Message(messageId, groupId, timestamp, raw);
 	}
 
 	public boolean hasOffer() throws IOException {
@@ -155,28 +156,7 @@ class PacketReaderImpl implements PacketReader {
 
 	public Offer readOffer() throws IOException {
 		if (!hasOffer()) throw new FormatException();
-		// Set up the reader
-		InputStream bais = new ByteArrayInputStream(payload, 0, payloadLength);
-		BdfReader r = bdfReaderFactory.createReader(bais);
-		// Read the start of the payload
-		r.readListStart();
-		// Read the message IDs
-		List<MessageId> offered = new ArrayList<MessageId>();
-		r.readListStart();
-		while (!r.hasListEnd()) {
-			byte[] b = r.readRaw(UniqueId.LENGTH);
-			if (b.length != UniqueId.LENGTH)
-				throw new FormatException();
-			offered.add(new MessageId(b));
-		}
-		if (offered.isEmpty()) throw new FormatException();
-		r.readListEnd();
-		// Read the end of the payload
-		r.readListEnd();
-		if (!r.eof()) throw new FormatException();
-		state = State.BUFFER_EMPTY;
-		// Build and return the offer
-		return new Offer(Collections.unmodifiableList(offered));
+		return new Offer(Collections.unmodifiableList(readMessageIds()));
 	}
 
 	public boolean hasRequest() throws IOException {
@@ -185,28 +165,7 @@ class PacketReaderImpl implements PacketReader {
 
 	public Request readRequest() throws IOException {
 		if (!hasRequest()) throw new FormatException();
-		// Set up the reader
-		InputStream bais = new ByteArrayInputStream(payload, 0, payloadLength);
-		BdfReader r = bdfReaderFactory.createReader(bais);
-		// Read the start of the payload
-		r.readListStart();
-		// Read the message IDs
-		r.readListStart();
-		List<MessageId> requested = new ArrayList<MessageId>();
-		while (!r.hasListEnd()) {
-			byte[] b = r.readRaw(UniqueId.LENGTH);
-			if (b.length != UniqueId.LENGTH)
-				throw new FormatException();
-			requested.add(new MessageId(b));
-		}
-		if (requested.isEmpty()) throw new FormatException();
-		r.readListEnd();
-		// Read the end of the payload
-		r.readListEnd();
-		if (!r.eof()) throw new FormatException();
-		state = State.BUFFER_EMPTY;
-		// Build and return the request
-		return new Request(Collections.unmodifiableList(requested));
+		return new Request(Collections.unmodifiableList(readMessageIds()));
 	}
 
 	public boolean hasSubscriptionAck() throws IOException {
diff --git a/briar-core/src/org/briarproject/sync/PacketWriterImpl.java b/briar-core/src/org/briarproject/sync/PacketWriterImpl.java
index 9c734a8eb228ffd7616bbffcda7c4ea382ff6bc7..449d40f19e233a0ac03fb14562276dc6716f84ae 100644
--- a/briar-core/src/org/briarproject/sync/PacketWriterImpl.java
+++ b/briar-core/src/org/briarproject/sync/PacketWriterImpl.java
@@ -1,5 +1,6 @@
 package org.briarproject.sync;
 
+import org.briarproject.api.UniqueId;
 import org.briarproject.api.data.BdfWriter;
 import org.briarproject.api.data.BdfWriterFactory;
 import org.briarproject.api.sync.Ack;
@@ -19,12 +20,6 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 
-import static org.briarproject.api.data.DataConstants.LIST_END_LENGTH;
-import static org.briarproject.api.data.DataConstants.LIST_START_LENGTH;
-import static org.briarproject.api.data.DataConstants.UNIQUE_ID_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.HEADER_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.PROTOCOL_VERSION;
 import static org.briarproject.api.sync.PacketTypes.ACK;
 import static org.briarproject.api.sync.PacketTypes.OFFER;
 import static org.briarproject.api.sync.PacketTypes.REQUEST;
@@ -32,6 +27,9 @@ import static org.briarproject.api.sync.PacketTypes.SUBSCRIPTION_ACK;
 import static org.briarproject.api.sync.PacketTypes.SUBSCRIPTION_UPDATE;
 import static org.briarproject.api.sync.PacketTypes.TRANSPORT_ACK;
 import static org.briarproject.api.sync.PacketTypes.TRANSPORT_UPDATE;
+import static org.briarproject.api.sync.SyncConstants.MAX_PACKET_PAYLOAD_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.PACKET_HEADER_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.PROTOCOL_VERSION;
 
 // This class is not thread-safe
 class PacketWriterImpl implements PacketWriter {
@@ -44,9 +42,9 @@ class PacketWriterImpl implements PacketWriter {
 	PacketWriterImpl(BdfWriterFactory bdfWriterFactory, OutputStream out) {
 		this.bdfWriterFactory = bdfWriterFactory;
 		this.out = out;
-		header = new byte[HEADER_LENGTH];
+		header = new byte[PACKET_HEADER_LENGTH];
 		header[0] = PROTOCOL_VERSION;
-		payload = new ByteArrayOutputStream(MAX_PAYLOAD_LENGTH);
+		payload = new ByteArrayOutputStream(MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	public int getMaxMessagesForAck(long capacity) {
@@ -62,10 +60,9 @@ class PacketWriterImpl implements PacketWriter {
 	}
 
 	private int getMaxMessagesForPacket(long capacity) {
-		int payload = (int) Math.min(capacity - HEADER_LENGTH,
-				MAX_PAYLOAD_LENGTH);
-		int overhead = LIST_START_LENGTH * 2 + LIST_END_LENGTH * 2;
-		return (payload - overhead) / UNIQUE_ID_LENGTH;
+		int payload = (int) Math.min(capacity - PACKET_HEADER_LENGTH,
+				MAX_PACKET_PAYLOAD_LENGTH);
+		return payload / UniqueId.LENGTH;
 	}
 
 	private void writePacket(byte packetType) throws IOException {
@@ -77,13 +74,8 @@ class PacketWriterImpl implements PacketWriter {
 	}
 
 	public void writeAck(Ack a) throws IOException {
-		assert payload.size() == 0;
-		BdfWriter w = bdfWriterFactory.createWriter(payload);
-		w.writeListStart();
-		w.writeListStart();
-		for (MessageId m : a.getMessageIds()) w.writeRaw(m.getBytes());
-		w.writeListEnd();
-		w.writeListEnd();
+		if (payload.size() != 0) throw new IllegalStateException();
+		for (MessageId m : a.getMessageIds()) payload.write(m.getBytes());
 		writePacket(ACK);
 	}
 
@@ -95,29 +87,19 @@ class PacketWriterImpl implements PacketWriter {
 	}
 
 	public void writeOffer(Offer o) throws IOException {
-		assert payload.size() == 0;
-		BdfWriter w = bdfWriterFactory.createWriter(payload);
-		w.writeListStart();
-		w.writeListStart();
-		for (MessageId m : o.getMessageIds()) w.writeRaw(m.getBytes());
-		w.writeListEnd();
-		w.writeListEnd();
+		if (payload.size() != 0) throw new IllegalStateException();
+		for (MessageId m : o.getMessageIds()) payload.write(m.getBytes());
 		writePacket(OFFER);
 	}
 
 	public void writeRequest(Request r) throws IOException {
-		assert payload.size() == 0;
-		BdfWriter w = bdfWriterFactory.createWriter(payload);
-		w.writeListStart();
-		w.writeListStart();
-		for (MessageId m : r.getMessageIds()) w.writeRaw(m.getBytes());
-		w.writeListEnd();
-		w.writeListEnd();
+		if (payload.size() != 0) throw new IllegalStateException();
+		for (MessageId m : r.getMessageIds()) payload.write(m.getBytes());
 		writePacket(REQUEST);
 	}
 
 	public void writeSubscriptionAck(SubscriptionAck a) throws IOException {
-		assert payload.size() == 0;
+		if (payload.size() != 0) throw new IllegalStateException();
 		BdfWriter w = bdfWriterFactory.createWriter(payload);
 		w.writeListStart();
 		w.writeInteger(a.getVersion());
@@ -127,14 +109,14 @@ class PacketWriterImpl implements PacketWriter {
 
 	public void writeSubscriptionUpdate(SubscriptionUpdate u)
 			throws IOException {
-		assert payload.size() == 0;
+		if (payload.size() != 0) throw new IllegalStateException();
 		BdfWriter w = bdfWriterFactory.createWriter(payload);
 		w.writeListStart();
 		w.writeListStart();
 		for (Group g : u.getGroups()) {
 			w.writeListStart();
-			w.writeString(g.getName());
-			w.writeRaw(g.getSalt());
+			w.writeRaw(g.getClientId().getBytes());
+			w.writeRaw(g.getDescriptor());
 			w.writeListEnd();
 		}
 		w.writeListEnd();
@@ -144,7 +126,7 @@ class PacketWriterImpl implements PacketWriter {
 	}
 
 	public void writeTransportAck(TransportAck a) throws IOException {
-		assert payload.size() == 0;
+		if (payload.size() != 0) throw new IllegalStateException();
 		BdfWriter w = bdfWriterFactory.createWriter(payload);
 		w.writeListStart();
 		w.writeString(a.getId().getString());
@@ -154,7 +136,7 @@ class PacketWriterImpl implements PacketWriter {
 	}
 
 	public void writeTransportUpdate(TransportUpdate u) throws IOException {
-		assert payload.size() == 0;
+		if (payload.size() != 0) throw new IllegalStateException();
 		BdfWriter w = bdfWriterFactory.createWriter(payload);
 		w.writeListStart();
 		w.writeString(u.getId().getString());
diff --git a/briar-core/src/org/briarproject/sync/SigningConsumer.java b/briar-core/src/org/briarproject/sync/SigningConsumer.java
deleted file mode 100644
index 4947cd659106c737b5bf889917cc3875379d8416..0000000000000000000000000000000000000000
--- a/briar-core/src/org/briarproject/sync/SigningConsumer.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.api.crypto.Signature;
-import org.briarproject.api.data.Consumer;
-
-/** A consumer that passes its input through a signature. */
-class SigningConsumer implements Consumer {
-
-	private final Signature signature;
-
-	public SigningConsumer(Signature signature) {
-		this.signature = signature;
-	}
-
-	public void write(byte b) {
-		signature.update(b);
-	}
-
-	public void write(byte[] b, int off, int len) {
-		signature.update(b, off, len);
-	}
-}
diff --git a/briar-core/src/org/briarproject/sync/SimplexOutgoingSession.java b/briar-core/src/org/briarproject/sync/SimplexOutgoingSession.java
index ace1d016cf1671a5acd9208b2638e34504563dda..f1504b9c825c674a7b8a618165e008fca1cca708 100644
--- a/briar-core/src/org/briarproject/sync/SimplexOutgoingSession.java
+++ b/briar-core/src/org/briarproject/sync/SimplexOutgoingSession.java
@@ -11,10 +11,10 @@ import org.briarproject.api.event.EventListener;
 import org.briarproject.api.event.ShutdownEvent;
 import org.briarproject.api.event.TransportRemovedEvent;
 import org.briarproject.api.sync.Ack;
-import org.briarproject.api.sync.MessagingSession;
 import org.briarproject.api.sync.PacketWriter;
 import org.briarproject.api.sync.SubscriptionAck;
 import org.briarproject.api.sync.SubscriptionUpdate;
+import org.briarproject.api.sync.SyncSession;
 import org.briarproject.api.sync.TransportAck;
 import org.briarproject.api.sync.TransportUpdate;
 
@@ -28,15 +28,15 @@ import java.util.logging.Logger;
 
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_PACKET_PAYLOAD_LENGTH;
 
 /**
- * An outgoing {@link org.briarproject.api.sync.MessagingSession
- * MessagingSession} suitable for simplex transports. The session sends
- * messages without offering them, and closes its output stream when there are
- * no more packets to send.
+ * An outgoing {@link org.briarproject.api.sync.SyncSession SyncSession}
+ * suitable for simplex transports. The session sends messages without offering
+ * them first, and closes its output stream when there are no more packets to
+ * send.
  */
-class SimplexOutgoingSession implements MessagingSession, EventListener {
+class SimplexOutgoingSession implements SyncSession, EventListener {
 
 	private static final Logger LOG =
 			Logger.getLogger(SimplexOutgoingSession.class.getName());
@@ -163,7 +163,7 @@ class SimplexOutgoingSession implements MessagingSession, EventListener {
 			if (interrupted) return;
 			try {
 				Collection<byte[]> b = db.generateBatch(contactId,
-						MAX_PAYLOAD_LENGTH, maxLatency);
+						MAX_PACKET_PAYLOAD_LENGTH, maxLatency);
 				if (LOG.isLoggable(INFO))
 					LOG.info("Generated batch: " + (b != null));
 				if (b == null) decrementOutstandingQueries();
diff --git a/briar-core/src/org/briarproject/sync/SubscriptionUpdateReader.java b/briar-core/src/org/briarproject/sync/SubscriptionUpdateReader.java
index 8f4545ff8c761836814c63dfa7b2ee86287a25ee..38a297f214d4aa5938038ea2b4a3ad074f01eeb9 100644
--- a/briar-core/src/org/briarproject/sync/SubscriptionUpdateReader.java
+++ b/briar-core/src/org/briarproject/sync/SubscriptionUpdateReader.java
@@ -2,7 +2,6 @@ package org.briarproject.sync;
 
 import org.briarproject.api.FormatException;
 import org.briarproject.api.data.BdfReader;
-import org.briarproject.api.data.Consumer;
 import org.briarproject.api.data.ObjectReader;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
@@ -15,8 +14,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_SUBSCRIPTIONS;
+import static org.briarproject.api.sync.SyncConstants.MAX_SUBSCRIPTIONS;
 
 class SubscriptionUpdateReader implements ObjectReader<SubscriptionUpdate> {
 
@@ -27,12 +25,7 @@ class SubscriptionUpdateReader implements ObjectReader<SubscriptionUpdate> {
 	}
 
 	public SubscriptionUpdate readObject(BdfReader r) throws IOException {
-		// Set up the reader
-		Consumer counting = new CountingConsumer(MAX_PAYLOAD_LENGTH);
-		r.addConsumer(counting);
-		// Read the start of the update
 		r.readListStart();
-		// Read the subscriptions, rejecting duplicates
 		List<Group> groups = new ArrayList<Group>();
 		Set<GroupId> ids = new HashSet<GroupId>();
 		r.readListStart();
@@ -42,14 +35,9 @@ class SubscriptionUpdateReader implements ObjectReader<SubscriptionUpdate> {
 			groups.add(g);
 		}
 		r.readListEnd();
-		// Read the version number
 		long version = r.readInteger();
 		if (version < 0) throw new FormatException();
-		// Read the end of the update
 		r.readListEnd();
-		// Reset the reader
-		r.removeConsumer(counting);
-		// Build and return the subscription update
 		groups = Collections.unmodifiableList(groups);
 		return new SubscriptionUpdate(groups, version);
 	}
diff --git a/briar-core/src/org/briarproject/sync/SyncModule.java b/briar-core/src/org/briarproject/sync/SyncModule.java
index 1caef5bf1ff92c78c0cfaf8bb2ba0b281f9ad853..cce031fd94941fe09e7059325a3118861052f308 100644
--- a/briar-core/src/org/briarproject/sync/SyncModule.java
+++ b/briar-core/src/org/briarproject/sync/SyncModule.java
@@ -3,19 +3,18 @@ package org.briarproject.sync;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 
-import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.data.ObjectReader;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.lifecycle.LifecycleManager;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupFactory;
 import org.briarproject.api.sync.MessageFactory;
-import org.briarproject.api.sync.MessageVerifier;
-import org.briarproject.api.sync.MessagingSessionFactory;
 import org.briarproject.api.sync.PacketReaderFactory;
 import org.briarproject.api.sync.PacketWriterFactory;
 import org.briarproject.api.sync.SubscriptionUpdate;
-import org.briarproject.api.sync.UnverifiedMessage;
+import org.briarproject.api.sync.SyncSessionFactory;
+import org.briarproject.api.sync.ValidationManager;
 
 import javax.inject.Singleton;
 
@@ -26,28 +25,20 @@ public class SyncModule extends AbstractModule {
 		bind(AuthorFactory.class).to(AuthorFactoryImpl.class);
 		bind(GroupFactory.class).to(GroupFactoryImpl.class);
 		bind(MessageFactory.class).to(MessageFactoryImpl.class);
-		bind(MessageVerifier.class).to(MessageVerifierImpl.class);
 		bind(PacketReaderFactory.class).to(PacketReaderFactoryImpl.class);
 		bind(PacketWriterFactory.class).to(PacketWriterFactoryImpl.class);
-		bind(MessagingSessionFactory.class).to(
-				MessagingSessionFactoryImpl.class).in(Singleton.class);
+		bind(SyncSessionFactory.class).to(
+				SyncSessionFactoryImpl.class).in(Singleton.class);
 	}
 
 	@Provides
-	ObjectReader<Author> getAuthorReader(CryptoComponent crypto) {
-		return new AuthorReader(crypto);
+	ObjectReader<Author> getAuthorReader(AuthorFactory authorFactory) {
+		return new AuthorReader(authorFactory);
 	}
 
 	@Provides
-	ObjectReader<Group> getGroupReader(CryptoComponent crypto) {
-		return new GroupReader(crypto);
-	}
-
-	@Provides
-	ObjectReader<UnverifiedMessage> getMessageReader(
-			ObjectReader<Group> groupReader,
-			ObjectReader<Author> authorReader) {
-		return new MessageReader(groupReader, authorReader);
+	ObjectReader<Group> getGroupReader(GroupFactory groupFactory) {
+		return new GroupReader(groupFactory);
 	}
 
 	@Provides
@@ -55,4 +46,11 @@ public class SyncModule extends AbstractModule {
 			ObjectReader<Group> groupReader) {
 		return new SubscriptionUpdateReader(groupReader);
 	}
+
+	@Provides @Singleton
+	ValidationManager getValidationManager(LifecycleManager lifecycleManager,
+			ValidationManagerImpl validationManager) {
+		lifecycleManager.register(validationManager);
+		return validationManager;
+	}
 }
diff --git a/briar-core/src/org/briarproject/sync/MessagingSessionFactoryImpl.java b/briar-core/src/org/briarproject/sync/SyncSessionFactoryImpl.java
similarity index 57%
rename from briar-core/src/org/briarproject/sync/MessagingSessionFactoryImpl.java
rename to briar-core/src/org/briarproject/sync/SyncSessionFactoryImpl.java
index ef3317dc87487b8198eb88f392f69418eeeae522..5a4021b1d41ba409a1bc2394edbcad0dc0043f24 100644
--- a/briar-core/src/org/briarproject/sync/MessagingSessionFactoryImpl.java
+++ b/briar-core/src/org/briarproject/sync/SyncSessionFactoryImpl.java
@@ -2,17 +2,15 @@ package org.briarproject.sync;
 
 import org.briarproject.api.TransportId;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.crypto.CryptoExecutor;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DatabaseExecutor;
 import org.briarproject.api.event.EventBus;
-import org.briarproject.api.sync.MessageVerifier;
-import org.briarproject.api.sync.MessagingSession;
-import org.briarproject.api.sync.MessagingSessionFactory;
 import org.briarproject.api.sync.PacketReader;
 import org.briarproject.api.sync.PacketReaderFactory;
 import org.briarproject.api.sync.PacketWriter;
 import org.briarproject.api.sync.PacketWriterFactory;
+import org.briarproject.api.sync.SyncSession;
+import org.briarproject.api.sync.SyncSessionFactory;
 import org.briarproject.api.system.Clock;
 
 import java.io.InputStream;
@@ -21,49 +19,44 @@ import java.util.concurrent.Executor;
 
 import javax.inject.Inject;
 
-class MessagingSessionFactoryImpl implements MessagingSessionFactory {
+class SyncSessionFactoryImpl implements SyncSessionFactory {
 
 	private final DatabaseComponent db;
-	private final Executor dbExecutor, cryptoExecutor;
-	private final MessageVerifier messageVerifier;
+	private final Executor dbExecutor;
 	private final EventBus eventBus;
 	private final Clock clock;
 	private final PacketReaderFactory packetReaderFactory;
 	private final PacketWriterFactory packetWriterFactory;
 
 	@Inject
-	MessagingSessionFactoryImpl(DatabaseComponent db,
-			@DatabaseExecutor Executor dbExecutor,
-			@CryptoExecutor Executor cryptoExecutor,
-			MessageVerifier messageVerifier, EventBus eventBus, Clock clock,
-			PacketReaderFactory packetReaderFactory,
+	SyncSessionFactoryImpl(DatabaseComponent db,
+			@DatabaseExecutor Executor dbExecutor, EventBus eventBus,
+			Clock clock, PacketReaderFactory packetReaderFactory,
 			PacketWriterFactory packetWriterFactory) {
 		this.db = db;
 		this.dbExecutor = dbExecutor;
-		this.cryptoExecutor = cryptoExecutor;
-		this.messageVerifier = messageVerifier;
 		this.eventBus = eventBus;
 		this.clock = clock;
 		this.packetReaderFactory = packetReaderFactory;
 		this.packetWriterFactory = packetWriterFactory;
 	}
 
-	public MessagingSession createIncomingSession(ContactId c, TransportId t,
+	public SyncSession createIncomingSession(ContactId c, TransportId t,
 			InputStream in) {
 		PacketReader packetReader = packetReaderFactory.createPacketReader(in);
-		return new IncomingSession(db, dbExecutor, cryptoExecutor, eventBus,
-				messageVerifier, c, t, packetReader);
+		return new IncomingSession(db, dbExecutor, eventBus, c, t,
+				packetReader);
 	}
 
-	public MessagingSession createSimplexOutgoingSession(ContactId c,
-			TransportId t, int maxLatency, OutputStream out) {
+	public SyncSession createSimplexOutgoingSession(ContactId c, TransportId t,
+			int maxLatency, OutputStream out) {
 		PacketWriter packetWriter = packetWriterFactory.createPacketWriter(out);
 		return new SimplexOutgoingSession(db, dbExecutor, eventBus, c, t,
 				maxLatency, packetWriter);
 	}
 
-	public MessagingSession createDuplexOutgoingSession(ContactId c,
-			TransportId t, int maxLatency, int maxIdleTime, OutputStream out) {
+	public SyncSession createDuplexOutgoingSession(ContactId c, TransportId t,
+			int maxLatency, int maxIdleTime, OutputStream out) {
 		PacketWriter packetWriter = packetWriterFactory.createPacketWriter(out);
 		return new DuplexOutgoingSession(db, dbExecutor, eventBus, clock, c, t,
 				maxLatency, maxIdleTime, packetWriter);
diff --git a/briar-core/src/org/briarproject/sync/ValidationManagerImpl.java b/briar-core/src/org/briarproject/sync/ValidationManagerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..68040be941b37f2aa0edf7ccef1a59f5ec91fd78
--- /dev/null
+++ b/briar-core/src/org/briarproject/sync/ValidationManagerImpl.java
@@ -0,0 +1,162 @@
+package org.briarproject.sync;
+
+import com.google.inject.Inject;
+
+import org.briarproject.api.UniqueId;
+import org.briarproject.api.crypto.CryptoExecutor;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DatabaseExecutor;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.db.NoSuchMessageException;
+import org.briarproject.api.db.NoSuchSubscriptionException;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.EventListener;
+import org.briarproject.api.event.MessageAddedEvent;
+import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageValidator;
+import org.briarproject.api.sync.ValidationManager;
+import org.briarproject.util.ByteUtils;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+
+class ValidationManagerImpl implements ValidationManager, EventListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(ValidationManagerImpl.class.getName());
+
+	private final DatabaseComponent db;
+	private final Executor dbExecutor;
+	private final Executor cryptoExecutor;
+	private final EventBus eventBus;
+	private final Map<ClientId, MessageValidator> validators;
+
+	@Inject
+	ValidationManagerImpl(DatabaseComponent db,
+			@DatabaseExecutor Executor dbExecutor,
+			@CryptoExecutor Executor cryptoExecutor, EventBus eventBus) {
+		this.db = db;
+		this.dbExecutor = dbExecutor;
+		this.cryptoExecutor = cryptoExecutor;
+		this.eventBus = eventBus;
+		validators = new ConcurrentHashMap<ClientId, MessageValidator>();
+	}
+
+	@Override
+	public boolean start() {
+		eventBus.addListener(this);
+		return true;
+	}
+
+	@Override
+	public boolean stop() {
+		eventBus.removeListener(this);
+		return true;
+	}
+
+	@Override
+	public void setMessageValidator(ClientId c, MessageValidator v) {
+		validators.put(c, v);
+		getMessagesToValidate(c);
+	}
+
+	private void getMessagesToValidate(final ClientId c) {
+		dbExecutor.execute(new Runnable() {
+			public void run() {
+				try {
+					// TODO: Don't do all of this in a single DB task
+					for (MessageId id : db.getMessagesToValidate(c)) {
+						try {
+							Message m = parseMessage(id, db.getRawMessage(id));
+							validateMessage(m, c);
+						} catch (NoSuchMessageException e) {
+							LOG.info("Message removed before validation");
+						}
+					}
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				}
+			}
+		});
+	}
+
+	private Message parseMessage(MessageId id, byte[] raw) {
+		if (raw.length <= MESSAGE_HEADER_LENGTH)
+			throw new IllegalArgumentException();
+		byte[] groupId = new byte[UniqueId.LENGTH];
+		System.arraycopy(raw, 0, groupId, 0, UniqueId.LENGTH);
+		long timestamp = ByteUtils.readUint64(raw, UniqueId.LENGTH);
+		return new Message(id, new GroupId(groupId), timestamp, raw);
+	}
+
+	private void validateMessage(final Message m, final ClientId c) {
+		cryptoExecutor.execute(new Runnable() {
+			public void run() {
+				MessageValidator v = validators.get(c);
+				if (v == null) {
+					LOG.warning("No validator");
+				} else {
+					Metadata meta = v.validateMessage(m);
+					storeValidationResult(m, c, meta);
+				}
+			}
+		});
+	}
+
+	private void storeValidationResult(final Message m, final ClientId c,
+			final Metadata meta) {
+		dbExecutor.execute(new Runnable() {
+			public void run() {
+				try {
+					if (meta == null) {
+						db.setMessageValidity(m, c, false);
+					} else {
+						db.mergeMessageMetadata(m.getId(), meta);
+						db.setMessageValidity(m, c, true);
+					}
+				} catch (NoSuchMessageException e) {
+					LOG.info("Message removed during validation");
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				}
+			}
+		});
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof MessageAddedEvent) {
+			MessageAddedEvent m = (MessageAddedEvent) e;
+			// Validate the message if it wasn't created locally
+			if (m.getContactId() != null) loadClientId(m.getMessage());
+		}
+	}
+
+	private void loadClientId(final Message m) {
+		dbExecutor.execute(new Runnable() {
+			public void run() {
+				try {
+					ClientId c = db.getGroup(m.getGroupId()).getClientId();
+					validateMessage(m, c);
+				} catch (NoSuchSubscriptionException e) {
+					LOG.info("Group removed before validation");
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				}
+			}
+		});
+	}
+}
diff --git a/briar-core/src/org/briarproject/util/ByteUtils.java b/briar-core/src/org/briarproject/util/ByteUtils.java
index 9213e65d8931072c3a30af52935bf1f330c2f1fa..061a4780db9d5bf5c9f6eee71e87ad3c6f06b8df 100644
--- a/briar-core/src/org/briarproject/util/ByteUtils.java
+++ b/briar-core/src/org/briarproject/util/ByteUtils.java
@@ -70,6 +70,19 @@ public class ByteUtils {
 				| (src[offset + 3] & 0xFFL);
 	}
 
+	public static long readUint64(byte[] src, int offset) {
+		if (src.length < offset + INT_64_BYTES)
+			throw new IllegalArgumentException();
+		return ((src[offset] & 0xFFL) << 56)
+				| ((src[offset + 1] & 0xFFL) << 48)
+				| ((src[offset + 2] & 0xFFL) << 40)
+				| ((src[offset + 3] & 0xFFL) << 32)
+				| ((src[offset + 4] & 0xFFL) << 24)
+				| ((src[offset + 5] & 0xFFL) << 16)
+				| ((src[offset + 6] & 0xFFL) << 8)
+				| (src[offset + 7] & 0xFFL);
+	}
+
 	public static int readUint(byte[] src, int bits) {
 		if (src.length << 3 < bits) throw new IllegalArgumentException();
 		int dest = 0;
diff --git a/briar-tests/build.xml b/briar-tests/build.xml
index 7508f612df21efbbe4906b074b35fa519479b5c5..408832ae8b3c16c6543378cffc8f1e64198945fa 100644
--- a/briar-tests/build.xml
+++ b/briar-tests/build.xml
@@ -125,7 +125,6 @@
 			<test name='org.briarproject.plugins.modem.ModemPluginTest'/>
 			<test name='org.briarproject.plugins.tcp.LanTcpPluginTest'/>
 			<test name='org.briarproject.sync.ConstantsTest'/>
-			<test name='org.briarproject.sync.ConsumersTest'/>
 			<test name='org.briarproject.sync.PacketReaderImplTest'/>
 			<test name='org.briarproject.sync.SimplexMessagingIntegrationTest'/>
 			<test name='org.briarproject.sync.SimplexOutgoingSessionTest'/>
diff --git a/briar-tests/src/org/briarproject/ProtocolIntegrationTest.java b/briar-tests/src/org/briarproject/ProtocolIntegrationTest.java
index 807b04c185444162b03a2d81189e346cd2079695..98975f205f2e6cedebc0dff4c377aafe35e22e62 100644
--- a/briar-tests/src/org/briarproject/ProtocolIntegrationTest.java
+++ b/briar-tests/src/org/briarproject/ProtocolIntegrationTest.java
@@ -6,18 +6,14 @@ import com.google.inject.Injector;
 import org.briarproject.api.TransportId;
 import org.briarproject.api.TransportProperties;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.crypto.CryptoComponent;
-import org.briarproject.api.crypto.KeyPair;
 import org.briarproject.api.crypto.SecretKey;
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.sync.Ack;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupFactory;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageFactory;
 import org.briarproject.api.sync.MessageId;
-import org.briarproject.api.sync.MessageVerifier;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.PacketReader;
 import org.briarproject.api.sync.PacketReaderFactory;
@@ -26,7 +22,6 @@ import org.briarproject.api.sync.PacketWriterFactory;
 import org.briarproject.api.sync.Request;
 import org.briarproject.api.sync.SubscriptionUpdate;
 import org.briarproject.api.sync.TransportUpdate;
-import org.briarproject.api.sync.UnverifiedMessage;
 import org.briarproject.api.transport.StreamContext;
 import org.briarproject.api.transport.StreamReaderFactory;
 import org.briarproject.api.transport.StreamWriterFactory;
@@ -46,6 +41,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
 import static org.briarproject.api.transport.TransportConstants.TAG_LENGTH;
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
@@ -58,7 +54,6 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 	private final StreamWriterFactory streamWriterFactory;
 	private final PacketReaderFactory packetReaderFactory;
 	private final PacketWriterFactory packetWriterFactory;
-	private final MessageVerifier messageVerifier;
 
 	private final ContactId contactId;
 	private final SecretKey tagKey, headerKey;
@@ -78,29 +73,22 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 		streamWriterFactory = i.getInstance(StreamWriterFactory.class);
 		packetReaderFactory = i.getInstance(PacketReaderFactory.class);
 		packetWriterFactory = i.getInstance(PacketWriterFactory.class);
-		messageVerifier = i.getInstance(MessageVerifier.class);
 		contactId = new ContactId(234);
 		// Create the transport keys
 		tagKey = TestUtils.createSecretKey();
 		headerKey = TestUtils.createSecretKey();
 		// Create a group
 		GroupFactory groupFactory = i.getInstance(GroupFactory.class);
-		group = groupFactory.createGroup("Group");
-		// Create an author
-		AuthorFactory authorFactory = i.getInstance(AuthorFactory.class);
-		CryptoComponent crypto = i.getInstance(CryptoComponent.class);
-		KeyPair authorKeyPair = crypto.generateSignatureKeyPair();
-		Author author = authorFactory.createAuthor("Alice",
-				authorKeyPair.getPublic().getEncoded());
-		// Create two messages to the group: one anonymous, one pseudonymous
+		ClientId clientId = new ClientId(TestUtils.getRandomId());
+		byte[] descriptor = new byte[MAX_GROUP_DESCRIPTOR_LENGTH];
+		group = groupFactory.createGroup(clientId, descriptor);
+		// Add two messages to the group
 		MessageFactory messageFactory = i.getInstance(MessageFactory.class);
-		String contentType = "text/plain";
 		long timestamp = System.currentTimeMillis();
 		String messageBody = "Hello world";
-		message = messageFactory.createAnonymousMessage(null, group,
-				"text/plain", timestamp, messageBody.getBytes("UTF-8"));
-		message1 = messageFactory.createPseudonymousMessage(null, group,
-				author, authorKeyPair.getPrivate(), contentType, timestamp,
+		message = messageFactory.createMessage(group.getId(), timestamp,
+				messageBody.getBytes("UTF-8"));
+		message1 = messageFactory.createMessage(group.getId(), timestamp,
 				messageBody.getBytes("UTF-8"));
 		messageIds = Arrays.asList(message.getId(), message1.getId());
 		// Create some transport properties
@@ -125,8 +113,8 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 
 		packetWriter.writeAck(new Ack(messageIds));
 
-		packetWriter.writeMessage(message.getSerialised());
-		packetWriter.writeMessage(message1.getSerialised());
+		packetWriter.writeMessage(message.getRaw());
+		packetWriter.writeMessage(message1.getRaw());
 
 		packetWriter.writeOffer(new Offer(messageIds));
 
@@ -163,11 +151,11 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 
 		// Read and verify the messages
 		assertTrue(packetReader.hasMessage());
-		UnverifiedMessage m = packetReader.readMessage();
-		checkMessageEquality(message, messageVerifier.verifyMessage(m));
+		Message m = packetReader.readMessage();
+		checkMessageEquality(message, m);
 		assertTrue(packetReader.hasMessage());
 		m = packetReader.readMessage();
-		checkMessageEquality(message1, messageVerifier.verifyMessage(m));
+		checkMessageEquality(message1, m);
 		assertFalse(packetReader.hasMessage());
 
 		// Read the offer
@@ -198,10 +186,7 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 
 	private void checkMessageEquality(Message m1, Message m2) {
 		assertEquals(m1.getId(), m2.getId());
-		assertEquals(m1.getParent(), m2.getParent());
-		assertEquals(m1.getGroup(), m2.getGroup());
-		assertEquals(m1.getAuthor(), m2.getAuthor());
 		assertEquals(m1.getTimestamp(), m2.getTimestamp());
-		assertArrayEquals(m1.getSerialised(), m2.getSerialised());
+		assertArrayEquals(m1.getRaw(), m2.getRaw());
 	}
 }
diff --git a/briar-tests/src/org/briarproject/TestMessage.java b/briar-tests/src/org/briarproject/TestMessage.java
deleted file mode 100644
index a8ffc3cb3a3e45ab2cb53c3146e93d0a55bffd9a..0000000000000000000000000000000000000000
--- a/briar-tests/src/org/briarproject/TestMessage.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package org.briarproject;
-
-import org.briarproject.api.identity.Author;
-import org.briarproject.api.sync.Group;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageId;
-
-public class TestMessage implements Message {
-
-	private final MessageId id, parent;
-	private final Group group;
-	private final Author author;
-	private final String contentType;
-	private final long timestamp;
-	private final byte[] raw;
-	private final int bodyStart, bodyLength;
-
-	public TestMessage(MessageId id, MessageId parent, Group group,
-			Author author, String contentType, long timestamp, byte[] raw) {
-		this(id, parent, group, author, contentType, timestamp, raw, 0,
-				raw.length);
-	}
-
-	public TestMessage(MessageId id, MessageId parent, Group group,
-			Author author, String contentType, long timestamp, byte[] raw,
-			int bodyStart, int bodyLength) {
-		this.id = id;
-		this.parent = parent;
-		this.group = group;
-		this.author = author;
-		this.contentType = contentType;
-		this.timestamp = timestamp;
-		this.raw = raw;
-		this.bodyStart = bodyStart;
-		this.bodyLength = bodyLength;
-	}
-
-	public MessageId getId() {
-		return id;
-	}
-
-	public MessageId getParent() {
-		return parent;
-	}
-
-	public Group getGroup() {
-		return group;
-	}
-
-	public Author getAuthor() {
-		return author;
-	}
-
-	public String getContentType() {
-		return contentType;
-	}
-
-	public long getTimestamp() {
-		return timestamp;
-	}
-
-	public byte[] getSerialised() {
-		return raw;
-	}
-
-	public int getBodyStart() {
-		return bodyStart;
-	}
-
-	public int getBodyLength() {
-		return bodyLength;
-	}
-
-	@Override
-	public int hashCode() {
-		return id.hashCode();
-	}
-
-	@Override
-	public boolean equals(Object o) {
-		return o instanceof Message && id.equals(((Message)o).getId());
-	}
-}
diff --git a/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java b/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java
index ba44df11673c3571d85fcf4bdacd916c61e67580..a6bd31ab33e861eda3e7a2803a8fa01b9126a80c 100644
--- a/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java
+++ b/briar-tests/src/org/briarproject/db/DatabaseComponentImplTest.java
@@ -1,7 +1,6 @@
 package org.briarproject.db;
 
 import org.briarproject.BriarTestCase;
-import org.briarproject.TestMessage;
 import org.briarproject.TestUtils;
 import org.briarproject.api.Settings;
 import org.briarproject.api.TransportId;
@@ -10,8 +9,11 @@ import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.SecretKey;
 import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.MessageExistsException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.db.NoSuchContactException;
 import org.briarproject.api.db.NoSuchLocalAuthorException;
+import org.briarproject.api.db.NoSuchMessageException;
 import org.briarproject.api.db.NoSuchSubscriptionException;
 import org.briarproject.api.db.NoSuchTransportException;
 import org.briarproject.api.event.ContactAddedEvent;
@@ -25,6 +27,7 @@ import org.briarproject.api.event.MessageAddedEvent;
 import org.briarproject.api.event.MessageRequestedEvent;
 import org.briarproject.api.event.MessageToAckEvent;
 import org.briarproject.api.event.MessageToRequestEvent;
+import org.briarproject.api.event.MessageValidatedEvent;
 import org.briarproject.api.event.MessagesAckedEvent;
 import org.briarproject.api.event.MessagesSentEvent;
 import org.briarproject.api.event.SubscriptionAddedEvent;
@@ -34,6 +37,7 @@ import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.lifecycle.ShutdownManager;
 import org.briarproject.api.sync.Ack;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
@@ -56,7 +60,7 @@ import java.util.Collection;
 import java.util.Collections;
 
 import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.GROUP_SALT_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
 import static org.briarproject.db.DatabaseConstants.MAX_OFFERED_MESSAGES;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -66,28 +70,31 @@ import static org.junit.Assert.fail;
 
 public class DatabaseComponentImplTest extends BriarTestCase {
 
-	protected final Object txn = new Object();
-	protected final GroupId groupId;
-	protected final Group group;
-	protected final AuthorId authorId;
-	protected final Author author;
-	protected final AuthorId localAuthorId;
-	protected final LocalAuthor localAuthor;
-	protected final MessageId messageId, messageId1;
-	protected final String contentType;
-	protected final long timestamp;
-	protected final int size;
-	protected final byte[] raw;
-	protected final Message message, message1;
-	protected final TransportId transportId;
-	protected final TransportProperties transportProperties;
-	protected final int maxLatency;
-	protected final ContactId contactId;
-	protected final Contact contact;
+	private final Object txn = new Object();
+	private final ClientId clientId;
+	private final GroupId groupId;
+	private final Group group;
+	private final AuthorId authorId;
+	private final Author author;
+	private final AuthorId localAuthorId;
+	private final LocalAuthor localAuthor;
+	private final MessageId messageId, messageId1;
+	private final int size;
+	private final byte[] raw;
+	private final Message message;
+	private final Metadata metadata;
+	private final TransportId transportId;
+	private final TransportProperties transportProperties;
+	private final int maxLatency;
+	private final ContactId contactId;
+	private final Contact contact;
 
 	public DatabaseComponentImplTest() {
+		clientId = new ClientId(TestUtils.getRandomId());
 		groupId = new GroupId(TestUtils.getRandomId());
-		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH]);
+		ClientId clientId = new ClientId(TestUtils.getRandomId());
+		byte[] descriptor = new byte[MAX_GROUP_DESCRIPTOR_LENGTH];
+		group = new Group(groupId, clientId, descriptor);
 		authorId = new AuthorId(TestUtils.getRandomId());
 		author = new Author(authorId, "Alice", new byte[MAX_PUBLIC_KEY_LENGTH]);
 		localAuthorId = new AuthorId(TestUtils.getRandomId());
@@ -95,14 +102,12 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100], 1234);
 		messageId = new MessageId(TestUtils.getRandomId());
 		messageId1 = new MessageId(TestUtils.getRandomId());
-		contentType = "text/plain";
-		timestamp = System.currentTimeMillis();
+		long timestamp = System.currentTimeMillis();
 		size = 1234;
 		raw = new byte[size];
-		message = new TestMessage(messageId, null, group, author, contentType,
-				timestamp, raw);
-		message1 = new TestMessage(messageId1, messageId, group, null,
-				contentType, timestamp, raw);
+		message = new Message(messageId, groupId, timestamp, raw);
+		metadata = new Metadata();
+		metadata.put("foo", new byte[] {'b', 'a', 'r'});
 		transportId = new TransportId("id");
 		transportProperties = new TransportProperties(Collections.singletonMap(
 				"bar", "baz"));
@@ -125,9 +130,9 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
 		final EventBus eventBus = context.mock(EventBus.class);
 		context.checking(new Expectations() {{
-			exactly(11).of(database).startTransaction();
+			exactly(10).of(database).startTransaction();
 			will(returnValue(txn));
-			exactly(11).of(database).commitTransaction(txn);
+			exactly(10).of(database).commitTransaction(txn);
 			// open()
 			oneOf(database).open();
 			will(returnValue(false));
@@ -161,13 +166,8 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			// addGroup() again
 			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
-			// getMessageHeaders()
-			oneOf(database).containsGroup(txn, groupId);
-			will(returnValue(true));
-			oneOf(database).getMessageHeaders(txn, groupId);
-			will(returnValue(Collections.emptyList()));
 			// getGroups()
-			oneOf(database).getGroups(txn);
+			oneOf(database).getGroups(txn, clientId);
 			will(returnValue(Collections.singletonList(group)));
 			// removeGroup()
 			oneOf(database).containsGroup(txn, groupId);
@@ -182,8 +182,6 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			// removeContact()
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			oneOf(database).getInboxGroupId(txn, contactId);
-			will(returnValue(null));
 			oneOf(database).removeContact(txn, contactId);
 			oneOf(eventBus).broadcast(with(any(ContactRemovedEvent.class)));
 			// removeLocalAuthor()
@@ -208,8 +206,7 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 				db.getRemoteProperties(transportId));
 		db.addGroup(group); // First time - listeners called
 		db.addGroup(group); // Second time - not called
-		assertEquals(Collections.emptyList(), db.getMessageHeaders(groupId));
-		assertEquals(Collections.singletonList(group), db.getGroups());
+		assertEquals(Collections.singletonList(group), db.getGroups(clientId));
 		db.removeGroup(group);
 		db.removeContact(contactId);
 		db.removeLocalAuthor(localAuthorId);
@@ -230,14 +227,17 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			will(returnValue(txn));
 			oneOf(database).containsMessage(txn, messageId);
 			will(returnValue(true));
-			oneOf(database).containsGroup(txn, groupId);
-			will(returnValue(true));
-			oneOf(database).commitTransaction(txn);
+			oneOf(database).abortTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, eventBus,
 				shutdown);
 
-		db.addLocalMessage(message);
+		try {
+			db.addLocalMessage(message, clientId, metadata);
+			fail();
+		} catch (MessageExistsException expected) {
+			// Expected
+		}
 
 		context.assertIsSatisfied();
 	}
@@ -257,12 +257,17 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			will(returnValue(false));
 			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(false));
-			oneOf(database).commitTransaction(txn);
+			oneOf(database).abortTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, eventBus,
 				shutdown);
 
-		db.addLocalMessage(message);
+		try {
+			db.addLocalMessage(message, clientId, metadata);
+			fail();
+		} catch (NoSuchSubscriptionException expected) {
+			// Expected
+		}
 
 		context.assertIsSatisfied();
 	}
@@ -282,7 +287,7 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
 			oneOf(database).addMessage(txn, message, true);
-			oneOf(database).setReadFlag(txn, messageId, true);
+			oneOf(database).mergeMessageMetadata(txn, messageId, metadata);
 			oneOf(database).getVisibility(txn, groupId);
 			will(returnValue(Collections.singletonList(contactId)));
 			oneOf(database).getContactIds(txn);
@@ -291,13 +296,14 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			will(returnValue(false));
 			oneOf(database).addStatus(txn, contactId, messageId, false, false);
 			oneOf(database).commitTransaction(txn);
-			// The message was added, so the listener should be called
+			// The message was added, so the listeners should be called
 			oneOf(eventBus).broadcast(with(any(MessageAddedEvent.class)));
+			oneOf(eventBus).broadcast(with(any(MessageValidatedEvent.class)));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, eventBus,
 				shutdown);
 
-		db.addLocalMessage(message);
+		db.addLocalMessage(message, clientId, metadata);
 
 		context.assertIsSatisfied();
 	}
@@ -385,7 +391,14 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 		}
 
 		try {
-			db.getInboxGroupId(contactId);
+			db.getMessageStatus(contactId, groupId);
+			fail();
+		} catch (NoSuchContactException expected) {
+			// Expected
+		}
+
+		try {
+			db.getMessageStatus(contactId, messageId);
 			fail();
 		} catch (NoSuchContactException expected) {
 			// Expected
@@ -469,13 +482,6 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			// Expected
 		}
 
-		try {
-			db.setInboxGroup(contactId, group);
-			fail();
-		} catch (NoSuchContactException expected) {
-			// Expected
-		}
-
 		context.assertIsSatisfied();
 	}
 
@@ -540,6 +546,9 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 			exactly(5).of(database).containsGroup(txn, groupId);
 			will(returnValue(false));
 			exactly(5).of(database).abortTransaction(txn);
+			// This is needed for getMessageStatus() to proceed
+			exactly(1).of(database).containsContact(txn, contactId);
+			will(returnValue(true));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, eventBus,
 				shutdown);
@@ -552,7 +561,7 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 		}
 
 		try {
-			db.getMessageHeaders(groupId);
+			db.getMessageStatus(contactId, groupId);
 			fail();
 		} catch (NoSuchSubscriptionException expected) {
 			// Expected
@@ -582,6 +591,59 @@ public class DatabaseComponentImplTest extends BriarTestCase {
 		context.assertIsSatisfied();
 	}
 
+	@Test
+	public void testVariousMethodsThrowExceptionIfMessageIsMissing()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final EventBus eventBus = context.mock(EventBus.class);
+		context.checking(new Expectations() {{
+			// Check whether the message is in the DB (which it's not)
+			exactly(4).of(database).startTransaction();
+			will(returnValue(txn));
+			exactly(4).of(database).containsMessage(txn, messageId);
+			will(returnValue(false));
+			exactly(4).of(database).abortTransaction(txn);
+			// This is needed for getMessageStatus() to proceed
+			exactly(1).of(database).containsContact(txn, contactId);
+			will(returnValue(true));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, eventBus,
+				shutdown);
+
+		try {
+			db.getRawMessage(messageId);
+			fail();
+		} catch (NoSuchMessageException expected) {
+			// Expected
+		}
+
+		try {
+			db.getMessageMetadata(messageId);
+			fail();
+		} catch (NoSuchMessageException expected) {
+			// Expected
+		}
+
+		try {
+			db.getMessageStatus(contactId, messageId);
+			fail();
+		} catch (NoSuchMessageException expected) {
+			// Expected
+		}
+
+		try {
+			db.mergeMessageMetadata(messageId, metadata);
+			fail();
+		} catch (NoSuchMessageException expected) {
+			// Expected
+		}
+
+		context.assertIsSatisfied();
+	}
+
 	@Test
 	public void testVariousMethodsThrowExceptionIfTransportIsMissing()
 			throws Exception {
diff --git a/briar-tests/src/org/briarproject/db/H2DatabaseTest.java b/briar-tests/src/org/briarproject/db/H2DatabaseTest.java
index 35076d6f26c8a14b13b6aa858080d5c5dbce5af6..f034e3f4be0165ea7d47cce36fd08af602427076 100644
--- a/briar-tests/src/org/briarproject/db/H2DatabaseTest.java
+++ b/briar-tests/src/org/briarproject/db/H2DatabaseTest.java
@@ -2,21 +2,22 @@ package org.briarproject.db;
 
 import org.briarproject.BriarTestCase;
 import org.briarproject.TestDatabaseConfig;
-import org.briarproject.TestMessage;
 import org.briarproject.TestUtils;
 import org.briarproject.api.TransportId;
 import org.briarproject.api.TransportProperties;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.crypto.SecretKey;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageHeader;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
 import org.briarproject.api.transport.IncomingKeys;
 import org.briarproject.api.transport.OutgoingKeys;
 import org.briarproject.api.transport.TransportKeys;
@@ -42,13 +43,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
 
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
-import static org.briarproject.api.sync.MessageHeader.State.STORED;
-import static org.briarproject.api.sync.MessagingConstants.GROUP_SALT_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_MESSAGE_LENGTH;
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -59,13 +58,13 @@ public class H2DatabaseTest extends BriarTestCase {
 
 	private final File testDir = TestUtils.getTestDirectory();
 	private final Random random = new Random();
+	private final ClientId clientId;
 	private final GroupId groupId;
 	private final Group group;
 	private final Author author;
 	private final AuthorId localAuthorId;
 	private final LocalAuthor localAuthor;
 	private final MessageId messageId;
-	private final String contentType;
 	private final long timestamp;
 	private final int size;
 	private final byte[] raw;
@@ -74,21 +73,21 @@ public class H2DatabaseTest extends BriarTestCase {
 	private final ContactId contactId;
 
 	public H2DatabaseTest() throws Exception {
+		clientId = new ClientId(TestUtils.getRandomId());
 		groupId = new GroupId(TestUtils.getRandomId());
-		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH]);
+		byte[] descriptor = new byte[MAX_GROUP_DESCRIPTOR_LENGTH];
+		group = new Group(groupId, clientId, descriptor);
 		AuthorId authorId = new AuthorId(TestUtils.getRandomId());
 		author = new Author(authorId, "Alice", new byte[MAX_PUBLIC_KEY_LENGTH]);
 		localAuthorId = new AuthorId(TestUtils.getRandomId());
 		localAuthor = new LocalAuthor(localAuthorId, "Bob",
 				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100], 1234);
 		messageId = new MessageId(TestUtils.getRandomId());
-		contentType = "text/plain";
 		timestamp = System.currentTimeMillis();
 		size = 1234;
 		raw = new byte[size];
 		random.nextBytes(raw);
-		message = new TestMessage(messageId, null, group, author, contentType,
-				timestamp, raw);
+		message = new Message(messageId, groupId, timestamp, raw);
 		transportId = new TransportId("id");
 		contactId = new ContactId(1);
 	}
@@ -309,8 +308,7 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// Add some messages to ack
 		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
-		Message message1 = new TestMessage(messageId1, null, group, author,
-				contentType, timestamp, raw);
+		Message message1 = new Message(messageId1, groupId, timestamp, raw);
 		db.addMessage(txn, message, true);
 		db.addStatus(txn, contactId, messageId, false, true);
 		db.raiseAckFlag(txn, contactId, messageId);
@@ -404,10 +402,9 @@ public class H2DatabaseTest extends BriarTestCase {
 
 	@Test
 	public void testGetFreeSpace() throws Exception {
-		byte[] largeBody = new byte[ONE_MEGABYTE];
+		byte[] largeBody = new byte[MAX_MESSAGE_LENGTH];
 		for (int i = 0; i < largeBody.length; i++) largeBody[i] = (byte) i;
-		Message message = new TestMessage(messageId, null, group, author,
-				contentType, timestamp, largeBody);
+		Message message = new Message(messageId, groupId, timestamp, largeBody);
 		Database<Connection> db = open(false);
 
 		// Sanity check: there should be enough space on disk for this test
@@ -718,343 +715,15 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.close();
 	}
 
-	@Test
-	public void testGetParentWithNoParent() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to a group
-		db.addGroup(txn, group);
-
-		// A message with no parent should return null
-		MessageId childId = new MessageId(TestUtils.getRandomId());
-		Message child = new TestMessage(childId, null, group, null, contentType,
-				timestamp, raw);
-		db.addMessage(txn, child, true);
-		assertTrue(db.containsMessage(txn, childId));
-		assertNull(db.getParent(txn, childId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetParentWithAbsentParent() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to a group
-		db.addGroup(txn, group);
-
-		// A message with an absent parent should return null
-		MessageId childId = new MessageId(TestUtils.getRandomId());
-		MessageId parentId = new MessageId(TestUtils.getRandomId());
-		Message child = new TestMessage(childId, parentId, group, null,
-				contentType, timestamp, raw);
-		db.addMessage(txn, child, true);
-		assertTrue(db.containsMessage(txn, childId));
-		assertFalse(db.containsMessage(txn, parentId));
-		assertNull(db.getParent(txn, childId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetParentWithParentInAnotherGroup() throws Exception {
-		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
-		Group group1 = new Group(groupId1, "Another group",
-				new byte[GROUP_SALT_LENGTH]);
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to two groups
-		db.addGroup(txn, group);
-		db.addGroup(txn, group1);
-
-		// A message with a parent in another group should return null
-		MessageId childId = new MessageId(TestUtils.getRandomId());
-		MessageId parentId = new MessageId(TestUtils.getRandomId());
-		Message child = new TestMessage(childId, parentId, group, null,
-				contentType, timestamp, raw);
-		Message parent = new TestMessage(parentId, null, group1, null,
-				contentType, timestamp, raw);
-		db.addMessage(txn, child, true);
-		db.addMessage(txn, parent, true);
-		assertTrue(db.containsMessage(txn, childId));
-		assertTrue(db.containsMessage(txn, parentId));
-		assertNull(db.getParent(txn, childId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetParentWithParentInSameGroup() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to a group
-		db.addGroup(txn, group);
-
-		// A message with a parent in the same group should return the parent
-		MessageId childId = new MessageId(TestUtils.getRandomId());
-		MessageId parentId = new MessageId(TestUtils.getRandomId());
-		Message child = new TestMessage(childId, parentId, group, null,
-				contentType, timestamp, raw);
-		Message parent = new TestMessage(parentId, null, group, null,
-				contentType, timestamp, raw);
-		db.addMessage(txn, child, true);
-		db.addMessage(txn, parent, true);
-		assertTrue(db.containsMessage(txn, childId));
-		assertTrue(db.containsMessage(txn, parentId));
-		assertEquals(parentId, db.getParent(txn, childId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetMessageBody() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Add a contact and subscribe to a group
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addGroup(txn, group);
-
-		// Store a couple of messages
-		int bodyLength = raw.length - 20;
-		Message message = new TestMessage(messageId, null, group, null,
-				contentType, timestamp, raw, 5, bodyLength);
-		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
-		Message message1 = new TestMessage(messageId1, null, group, null,
-				contentType, timestamp, raw, 10, bodyLength);
-		db.addMessage(txn, message, true);
-		db.addMessage(txn, message1, true);
-
-		// Calculate the expected message bodies
-		byte[] expectedBody = new byte[bodyLength];
-		System.arraycopy(raw, 5, expectedBody, 0, bodyLength);
-		assertFalse(Arrays.equals(expectedBody, new byte[bodyLength]));
-		byte[] expectedBody1 = new byte[bodyLength];
-		System.arraycopy(raw, 10, expectedBody1, 0, bodyLength);
-		System.arraycopy(raw, 10, expectedBody1, 0, bodyLength);
-
-		// Retrieve the raw messages
-		assertArrayEquals(raw, db.getRawMessage(txn, messageId));
-		assertArrayEquals(raw, db.getRawMessage(txn, messageId1));
-
-		// Retrieve the message bodies
-		byte[] body = db.getMessageBody(txn, messageId);
-		assertArrayEquals(expectedBody, body);
-		byte[] body1 = db.getMessageBody(txn, messageId1);
-		assertArrayEquals(expectedBody1, body1);
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetMessageHeaders() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to a group
-		db.addGroup(txn, group);
-
-		// Store a couple of messages
-		db.addMessage(txn, message, true);
-		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
-		MessageId parentId = new MessageId(TestUtils.getRandomId());
-		long timestamp1 = System.currentTimeMillis();
-		Message message1 = new TestMessage(messageId1, parentId, group, author,
-				contentType, timestamp1, raw);
-		db.addMessage(txn, message1, true);
-		// Mark one of the messages read
-		db.setReadFlag(txn, messageId, true);
-
-		// Retrieve the message headers (order is undefined)
-		Collection<MessageHeader> headers = db.getMessageHeaders(txn, groupId);
-		assertEquals(2, headers.size());
-		boolean firstFound = false, secondFound = false;
-		for (MessageHeader header : headers) {
-			if (messageId.equals(header.getId())) {
-				assertHeadersMatch(message, header);
-				assertTrue(header.isRead());
-				firstFound = true;
-			} else if (messageId1.equals(header.getId())) {
-				assertHeadersMatch(message1, header);
-				assertFalse(header.isRead());
-				secondFound = true;
-			} else {
-				fail();
-			}
-		}
-		// Both the headers should have been retrieved
-		assertTrue(firstFound);
-		assertTrue(secondFound);
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	private void assertHeadersMatch(Message m, MessageHeader h) {
-		assertEquals(m.getId(), h.getId());
-		if (m.getParent() == null) assertNull(h.getParent());
-		else assertEquals(m.getParent(), h.getParent());
-		assertEquals(m.getGroup().getId(), h.getGroupId());
-		if (m.getAuthor() == null) assertNull(h.getAuthor());
-		else assertEquals(m.getAuthor(), h.getAuthor());
-		assertEquals(m.getContentType(), h.getContentType());
-		assertEquals(m.getTimestamp(), h.getTimestamp());
-	}
-
-	@Test
-	public void testAuthorStatus() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Add a contact and subscribe to a group
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addGroup(txn, group);
-
-		// Store a message from the contact - status VERIFIED
-		db.addMessage(txn, message, true);
-		AuthorId authorId1 = new AuthorId(TestUtils.getRandomId());
-		// Store a message from an unknown author - status UNKNOWN
-		Author author1 = new Author(authorId1, "Bob",
-				new byte[MAX_PUBLIC_KEY_LENGTH]);
-		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
-		Message message1 = new TestMessage(messageId1, null, group, author1,
-				contentType, timestamp, raw);
-		db.addMessage(txn, message1, true);
-		// Store an anonymous message - status ANONYMOUS
-		MessageId messageId2 = new MessageId(TestUtils.getRandomId());
-		Message message2 = new TestMessage(messageId2, null, group, null,
-				contentType, timestamp, raw);
-		db.addMessage(txn, message2, true);
-
-		// Retrieve the message headers (order is undefined)
-		Collection<MessageHeader> headers = db.getMessageHeaders(txn, groupId);
-		assertEquals(3, headers.size());
-		boolean firstFound = false, secondFound = false, thirdFound = false;
-		for (MessageHeader header : headers) {
-			if (messageId.equals(header.getId())) {
-				assertHeadersMatch(message, header);
-				assertEquals(Author.Status.VERIFIED, header.getAuthorStatus());
-				firstFound = true;
-			} else if (messageId1.equals(header.getId())) {
-				assertHeadersMatch(message1, header);
-				assertEquals(Author.Status.UNKNOWN, header.getAuthorStatus());
-				secondFound = true;
-			} else if (messageId2.equals(header.getId())) {
-				assertHeadersMatch(message2, header);
-				assertEquals(Author.Status.ANONYMOUS, header.getAuthorStatus());
-				thirdFound = true;
-			} else {
-				fail();
-			}
-		}
-		// All of the headers should have been retrieved
-		assertTrue(firstFound);
-		assertTrue(secondFound);
-		assertTrue(thirdFound);
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testReadFlag() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to a group and store a message
-		db.addGroup(txn, group);
-		db.addMessage(txn, message, true);
-
-		// The message should be unread by default
-		assertFalse(db.getReadFlag(txn, messageId));
-		// Mark the message read
-		db.setReadFlag(txn, messageId, true);
-		// The message should be read
-		assertTrue(db.getReadFlag(txn, messageId));
-		// Mark the message unread
-		db.setReadFlag(txn, messageId, false);
-		// The message should be unread
-		assertFalse(db.getReadFlag(txn, messageId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetUnreadMessageCounts() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Subscribe to a couple of groups
-		db.addGroup(txn, group);
-		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
-		Group group1 = new Group(groupId1, "Another group",
-				new byte[GROUP_SALT_LENGTH]);
-		db.addGroup(txn, group1);
-
-		// Store two messages in the first group
-		db.addMessage(txn, message, true);
-		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
-		Message message1 = new TestMessage(messageId1, null, group, author,
-				contentType, timestamp, raw);
-		db.addMessage(txn, message1, true);
-
-		// Store one message in the second group
-		MessageId messageId2 = new MessageId(TestUtils.getRandomId());
-		Message message2 = new TestMessage(messageId2, null, group1, author,
-				contentType, timestamp, raw);
-		db.addMessage(txn, message2, true);
-
-		// Mark one of the messages in the first group read
-		db.setReadFlag(txn, messageId, true);
-
-		// There should be one unread message in each group
-		Map<GroupId, Integer> counts = db.getUnreadMessageCounts(txn);
-		assertEquals(2, counts.size());
-		Integer count = counts.get(groupId);
-		assertNotNull(count);
-		assertEquals(1, count.intValue());
-		count = counts.get(groupId1);
-		assertNotNull(count);
-		assertEquals(1, count.intValue());
-
-		// Mark the read message unread
-		db.setReadFlag(txn, messageId, false);
-
-		// Mark the message in the second group read
-		db.setReadFlag(txn, messageId2, true);
-
-		// There should be two unread messages in the first group, none in
-		// the second group
-		counts = db.getUnreadMessageCounts(txn);
-		assertEquals(1, counts.size());
-		count = counts.get(groupId);
-		assertNotNull(count);
-		assertEquals(2, count.intValue());
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
 	@Test
 	public void testMultipleSubscriptionsAndUnsubscriptions() throws Exception {
 		// Create some groups
 		List<Group> groups = new ArrayList<Group>();
 		for (int i = 0; i < 100; i++) {
 			GroupId id = new GroupId(TestUtils.getRandomId());
-			String name = "Group " + i;
-			groups.add(new Group(id, name, new byte[GROUP_SALT_LENGTH]));
+			ClientId clientId = new ClientId(TestUtils.getRandomId());
+			byte[] descriptor = new byte[MAX_GROUP_DESCRIPTOR_LENGTH];
+			groups.add(new Group(id, clientId, descriptor));
 		}
 
 		Database<Connection> db = open(false);
@@ -1233,31 +902,34 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.setGroups(txn, contactId1, Collections.singletonList(group), 1);
 
 		// The group should be available
-		assertEquals(Collections.emptyList(), db.getGroups(txn));
+		assertEquals(Collections.emptyList(), db.getGroups(txn, clientId));
 		assertEquals(Collections.singletonList(group),
-				db.getAvailableGroups(txn));
+				db.getAvailableGroups(txn, clientId));
 
 		// Subscribe to the group - it should no longer be available
 		db.addGroup(txn, group);
-		assertEquals(Collections.singletonList(group), db.getGroups(txn));
-		assertEquals(Collections.emptyList(), db.getAvailableGroups(txn));
+		assertEquals(Collections.singletonList(group),
+				db.getGroups(txn, clientId));
+		assertEquals(Collections.emptyList(),
+				db.getAvailableGroups(txn, clientId));
 
 		// Unsubscribe from the group - it should be available again
 		db.removeGroup(txn, groupId);
-		assertEquals(Collections.emptyList(), db.getGroups(txn));
+		assertEquals(Collections.emptyList(), db.getGroups(txn, clientId));
 		assertEquals(Collections.singletonList(group),
-				db.getAvailableGroups(txn));
+				db.getAvailableGroups(txn, clientId));
 
 		// The first contact unsubscribes - it should still be available
 		db.setGroups(txn, contactId, Collections.<Group>emptyList(), 2);
-		assertEquals(Collections.emptyList(), db.getGroups(txn));
+		assertEquals(Collections.emptyList(), db.getGroups(txn, clientId));
 		assertEquals(Collections.singletonList(group),
-				db.getAvailableGroups(txn));
+				db.getAvailableGroups(txn, clientId));
 
 		// The second contact unsubscribes - it should no longer be available
 		db.setGroups(txn, contactId1, Collections.<Group>emptyList(), 2);
-		assertEquals(Collections.emptyList(), db.getGroups(txn));
-		assertEquals(Collections.emptyList(), db.getAvailableGroups(txn));
+		assertEquals(Collections.emptyList(), db.getGroups(txn, clientId));
+		assertEquals(Collections.emptyList(),
+				db.getAvailableGroups(txn, clientId));
 
 		db.commitTransaction(txn);
 		db.close();
@@ -1288,41 +960,6 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.close();
 	}
 
-	@Test
-	public void testGetInboxMessageHeaders() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Add a contact and an inbox group - no headers should be returned
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addGroup(txn, group);
-		db.setInboxGroup(txn, contactId, group);
-		assertEquals(Collections.emptyList(),
-				db.getInboxMessageHeaders(txn, contactId));
-
-		// Add a message to the inbox group - the header should be returned
-		db.addMessage(txn, message, true);
-		db.addStatus(txn, contactId, messageId, false, false);
-		Collection<MessageHeader> headers =
-				db.getInboxMessageHeaders(txn, contactId);
-		assertEquals(1, headers.size());
-		MessageHeader header = headers.iterator().next();
-		assertEquals(messageId, header.getId());
-		assertNull(header.getParent());
-		assertEquals(groupId, header.getGroupId());
-		assertEquals(localAuthor, header.getAuthor());
-		assertEquals(contentType, header.getContentType());
-		assertEquals(timestamp, header.getTimestamp());
-		assertEquals(true, header.isLocal());
-		assertEquals(false, header.isRead());
-		assertEquals(STORED, header.getStatus());
-		assertFalse(header.isRead());
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
 	@Test
 	public void testOfferedMessages() throws Exception {
 		Database<Connection> db = open(false);
@@ -1392,6 +1029,116 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.close();
 	}
 
+	@Test
+	public void testMessageMetadata() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a group and a message
+		db.addGroup(txn, group);
+		db.addMessage(txn, message, true);
+
+		// Attach some metadata to the message
+		Metadata metadata = new Metadata();
+		metadata.put("foo", new byte[]{'b', 'a', 'r'});
+		db.mergeMessageMetadata(txn, messageId, metadata);
+
+		// Retrieve the metadata for the message
+		Metadata retrieved = db.getMessageMetadata(txn, messageId);
+		assertEquals(1, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata.get("foo"), retrieved.get("foo"));
+
+		// Retrieve the metadata for the group
+		Map<MessageId, Metadata> all = db.getMessageMetadata(txn, groupId);
+		assertEquals(1, all.size());
+		assertTrue(all.containsKey(messageId));
+		retrieved = all.get(messageId);
+		assertEquals(1, retrieved.size());
+		assertTrue(retrieved.containsKey("foo"));
+		assertArrayEquals(metadata.get("foo"), retrieved.get("foo"));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageStatus() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact who subscribes to a group
+		db.addLocalAuthor(txn, localAuthor);
+		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
+		db.setGroups(txn, contactId, Collections.singletonList(group), 1);
+
+		// Subscribe to the group and make it visible to the contact
+		db.addGroup(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+
+		// Add a message to the group
+		db.addMessage(txn, message, true);
+		db.addStatus(txn, contactId, messageId, false, false);
+
+		// The message should not be sent or seen
+		MessageStatus status = db.getMessageStatus(txn, contactId, messageId);
+		assertEquals(messageId, status.getMessageId());
+		assertEquals(contactId, status.getContactId());
+		assertFalse(status.isSent());
+		assertFalse(status.isSeen());
+
+		// The same status should be returned when querying by group
+		Collection<MessageStatus> statuses = db.getMessageStatus(txn,
+				contactId, groupId);
+		assertEquals(1, statuses.size());
+		status = statuses.iterator().next();
+		assertEquals(messageId, status.getMessageId());
+		assertEquals(contactId, status.getContactId());
+		assertFalse(status.isSent());
+		assertFalse(status.isSeen());
+
+		// Pretend the message was sent to the contact
+		db.updateExpiryTime(txn, contactId, messageId, Integer.MAX_VALUE);
+
+		// The message should be sent but not seen
+		status = db.getMessageStatus(txn, contactId, messageId);
+		assertEquals(messageId, status.getMessageId());
+		assertEquals(contactId, status.getContactId());
+		assertTrue(status.isSent());
+		assertFalse(status.isSeen());
+
+		// The same status should be returned when querying by group
+		statuses = db.getMessageStatus(txn, contactId, groupId);
+		assertEquals(1, statuses.size());
+		status = statuses.iterator().next();
+		assertEquals(messageId, status.getMessageId());
+		assertEquals(contactId, status.getContactId());
+		assertTrue(status.isSent());
+		assertFalse(status.isSeen());
+
+		// Pretend the message was acked by the contact
+		db.raiseSeenFlag(txn, contactId, messageId);
+
+		// The message should be sent and seen
+		status = db.getMessageStatus(txn, contactId, messageId);
+		assertEquals(messageId, status.getMessageId());
+		assertEquals(contactId, status.getContactId());
+		assertTrue(status.isSent());
+		assertTrue(status.isSeen());
+
+		// The same status should be returned when querying by group
+		statuses = db.getMessageStatus(txn, contactId, groupId);
+		assertEquals(1, statuses.size());
+		status = statuses.iterator().next();
+		assertEquals(messageId, status.getMessageId());
+		assertEquals(contactId, status.getContactId());
+		assertTrue(status.isSent());
+		assertTrue(status.isSeen());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
 	@Test
 	public void testExceptionHandling() throws Exception {
 		Database<Connection> db = open(false);
diff --git a/briar-tests/src/org/briarproject/sync/ConstantsTest.java b/briar-tests/src/org/briarproject/sync/ConstantsTest.java
index 00149a8a012d92d23f63ec10acf89923567b90f2..6b92a026952a8de35dfa2b8421df2e98e665dc62 100644
--- a/briar-tests/src/org/briarproject/sync/ConstantsTest.java
+++ b/briar-tests/src/org/briarproject/sync/ConstantsTest.java
@@ -15,13 +15,19 @@ import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.crypto.KeyPair;
 import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.crypto.Signature;
+import org.briarproject.api.forum.ForumConstants;
+import org.briarproject.api.forum.ForumPost;
+import org.briarproject.api.forum.ForumPostFactory;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.messaging.MessagingConstants;
+import org.briarproject.api.messaging.PrivateMessage;
+import org.briarproject.api.messaging.PrivateMessageFactory;
 import org.briarproject.api.sync.Ack;
+import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupFactory;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageFactory;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 import org.briarproject.api.sync.Offer;
 import org.briarproject.api.sync.PacketWriter;
@@ -33,6 +39,8 @@ import org.briarproject.crypto.CryptoModule;
 import org.briarproject.data.DataModule;
 import org.briarproject.db.DatabaseModule;
 import org.briarproject.event.EventModule;
+import org.briarproject.forum.ForumModule;
+import org.briarproject.messaging.MessagingModule;
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
@@ -43,14 +51,14 @@ import java.util.Random;
 import static org.briarproject.api.TransportPropertyConstants.MAX_PROPERTIES_PER_TRANSPORT;
 import static org.briarproject.api.TransportPropertyConstants.MAX_PROPERTY_LENGTH;
 import static org.briarproject.api.TransportPropertyConstants.MAX_TRANSPORT_ID_LENGTH;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_POST_BODY_LENGTH;
 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;
-import static org.briarproject.api.sync.MessagingConstants.MAX_BODY_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_GROUP_NAME_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_SUBSCRIPTIONS;
+import static org.briarproject.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_PACKET_PAYLOAD_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.MAX_SUBSCRIPTIONS;
 import static org.junit.Assert.assertTrue;
 
 public class ConstantsTest extends BriarTestCase {
@@ -58,18 +66,21 @@ public class ConstantsTest extends BriarTestCase {
 	private final CryptoComponent crypto;
 	private final GroupFactory groupFactory;
 	private final AuthorFactory authorFactory;
-	private final MessageFactory messageFactory;
+	private final PrivateMessageFactory privateMessageFactory;
+	private final ForumPostFactory forumPostFactory;
 	private final PacketWriterFactory packetWriterFactory;
 
 	public ConstantsTest() throws Exception {
 		Injector i = Guice.createInjector(new TestDatabaseModule(),
 				new TestLifecycleModule(), new TestSystemModule(),
-				new CryptoModule(), new DatabaseModule(), new EventModule(),
-				new SyncModule(), new DataModule());
+				new CryptoModule(), new DatabaseModule(), new DataModule(),
+				new EventModule(), new ForumModule(), new MessagingModule(),
+				new SyncModule());
 		crypto = i.getInstance(CryptoComponent.class);
 		groupFactory = i.getInstance(GroupFactory.class);
 		authorFactory = i.getInstance(AuthorFactory.class);
-		messageFactory = i.getInstance(MessageFactory.class);
+		privateMessageFactory = i.getInstance(PrivateMessageFactory.class);
+		forumPostFactory = i.getInstance(ForumPostFactory.class);
 		packetWriterFactory = i.getInstance(PacketWriterFactory.class);
 	}
 
@@ -100,14 +111,13 @@ public class ConstantsTest extends BriarTestCase {
 			sig.initSign(keyPair.getPrivate());
 			sig.update(toBeSigned);
 			byte[] signature = sig.sign();
-			assertTrue("Length " + signature.length,
-					signature.length <= MAX_SIGNATURE_LENGTH);
+			assertTrue(signature.length <= MAX_SIGNATURE_LENGTH);
 		}
 	}
 
 	@Test
 	public void testMessageIdsFitIntoLargeAck() throws Exception {
-		testMessageIdsFitIntoAck(MAX_PAYLOAD_LENGTH);
+		testMessageIdsFitIntoAck(MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	@Test
@@ -116,36 +126,53 @@ public class ConstantsTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testMessageFitsIntoPacket() throws Exception {
+	public void testPrivateMessageFitsIntoPacket() throws Exception {
+		// Create a maximum-length private message
+		GroupId groupId = new GroupId(TestUtils.getRandomId());
+		long timestamp = Long.MAX_VALUE;
 		MessageId parent = new MessageId(TestUtils.getRandomId());
-		// Create a maximum-length group
-		String groupName = TestUtils.createRandomString(MAX_GROUP_NAME_LENGTH);
-		Group group = groupFactory.createGroup(groupName);
+		String contentType = TestUtils.createRandomString(
+				MessagingConstants.MAX_CONTENT_TYPE_LENGTH);
+		byte[] body = new byte[MAX_PRIVATE_MESSAGE_BODY_LENGTH];
+		PrivateMessage message = privateMessageFactory.createPrivateMessage(
+				groupId, timestamp, parent, contentType, body);
+		// Check the size of the serialised message
+		int length = message.getMessage().getRaw().length;
+		assertTrue(length > UniqueId.LENGTH + 8 + UniqueId.LENGTH
+				+ MessagingConstants.MAX_CONTENT_TYPE_LENGTH
+				+ MAX_PRIVATE_MESSAGE_BODY_LENGTH);
+		assertTrue(length <= MAX_PACKET_PAYLOAD_LENGTH);
+	}
+
+	@Test
+	public void testForumPostFitsIntoPacket() throws Exception {
 		// Create a maximum-length author
-		String authorName =
-				TestUtils.createRandomString(MAX_AUTHOR_NAME_LENGTH);
+		String authorName = TestUtils.createRandomString(
+				MAX_AUTHOR_NAME_LENGTH);
 		byte[] authorPublic = new byte[MAX_PUBLIC_KEY_LENGTH];
 		Author author = authorFactory.createAuthor(authorName, authorPublic);
-		// Create a maximum-length message
-		PrivateKey privateKey = crypto.generateSignatureKeyPair().getPrivate();
-		String contentType =
-				TestUtils.createRandomString(MAX_CONTENT_TYPE_LENGTH);
+		// Create a maximum-length forum post
+		GroupId groupId = new GroupId(TestUtils.getRandomId());
 		long timestamp = Long.MAX_VALUE;
-		byte[] body = new byte[MAX_BODY_LENGTH];
-		Message message = messageFactory.createPseudonymousMessage(parent,
-				group, author, privateKey, contentType, timestamp, body);
+		MessageId parent = new MessageId(TestUtils.getRandomId());
+		String contentType = TestUtils.createRandomString(
+				ForumConstants.MAX_CONTENT_TYPE_LENGTH);
+		byte[] body = new byte[MAX_FORUM_POST_BODY_LENGTH];
+		PrivateKey privateKey = crypto.generateSignatureKeyPair().getPrivate();
+		ForumPost post = forumPostFactory.createPseudonymousPost(groupId,
+				timestamp, parent, author, contentType, body, privateKey);
 		// Check the size of the serialised message
-		int length = message.getSerialised().length;
-		assertTrue(length > UniqueId.LENGTH + MAX_GROUP_NAME_LENGTH
-				+ MAX_PUBLIC_KEY_LENGTH + MAX_AUTHOR_NAME_LENGTH
-				+ MAX_PUBLIC_KEY_LENGTH + MAX_CONTENT_TYPE_LENGTH
-				+ MAX_BODY_LENGTH);
-		assertTrue(length <= MAX_PAYLOAD_LENGTH);
+		int length = post.getMessage().getRaw().length;
+		assertTrue(length > UniqueId.LENGTH + 8 + UniqueId.LENGTH
+				+ MAX_AUTHOR_NAME_LENGTH + MAX_PUBLIC_KEY_LENGTH
+				+ ForumConstants.MAX_CONTENT_TYPE_LENGTH
+				+ MAX_FORUM_POST_BODY_LENGTH);
+		assertTrue(length <= MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	@Test
 	public void testMessageIdsFitIntoLargeOffer() throws Exception {
-		testMessageIdsFitIntoOffer(MAX_PAYLOAD_LENGTH);
+		testMessageIdsFitIntoOffer(MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	@Test
@@ -155,7 +182,7 @@ public class ConstantsTest extends BriarTestCase {
 
 	@Test
 	public void testMessageIdsFitIntoLargeRequest() throws Exception {
-		testMessageIdsFitIntoRequest(MAX_PAYLOAD_LENGTH);
+		testMessageIdsFitIntoRequest(MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	@Test
@@ -181,16 +208,19 @@ public class ConstantsTest extends BriarTestCase {
 		PacketWriter writer = packetWriterFactory.createPacketWriter(out);
 		writer.writeTransportUpdate(u);
 		// Check the size of the serialised transport update
-		assertTrue(out.size() <= MAX_PAYLOAD_LENGTH);
+		assertTrue(out.size() <= MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	@Test
 	public void testGroupsFitIntoSubscriptionUpdate() throws Exception {
 		// Create the maximum number of maximum-length groups
+		Random random = new Random();
+		ClientId clientId = new ClientId(TestUtils.getRandomId());
 		Collection<Group> groups = new ArrayList<Group>();
 		for (int i = 0; i < MAX_SUBSCRIPTIONS; i++) {
-			String name = TestUtils.createRandomString(MAX_GROUP_NAME_LENGTH);
-			groups.add(groupFactory.createGroup(name));
+			byte[] descriptor = new byte[MAX_GROUP_DESCRIPTOR_LENGTH];
+			random.nextBytes(descriptor);
+			groups.add(groupFactory.createGroup(clientId, descriptor));
 		}
 		// Create a maximum-length subscription update
 		SubscriptionUpdate u = new SubscriptionUpdate(groups, Long.MAX_VALUE);
@@ -199,7 +229,7 @@ public class ConstantsTest extends BriarTestCase {
 		PacketWriter writer = packetWriterFactory.createPacketWriter(out);
 		writer.writeSubscriptionUpdate(u);
 		// Check the size of the serialised subscription update
-		assertTrue(out.size() <= MAX_PAYLOAD_LENGTH);
+		assertTrue(out.size() <= MAX_PACKET_PAYLOAD_LENGTH);
 	}
 
 	private void testMessageIdsFitIntoAck(int length) throws Exception {
diff --git a/briar-tests/src/org/briarproject/sync/ConsumersTest.java b/briar-tests/src/org/briarproject/sync/ConsumersTest.java
deleted file mode 100644
index d19e0223da35b5b80df3c50714cfc08b5aede4fd..0000000000000000000000000000000000000000
--- a/briar-tests/src/org/briarproject/sync/ConsumersTest.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package org.briarproject.sync;
-
-import org.briarproject.BriarTestCase;
-import org.briarproject.api.FormatException;
-import org.briarproject.api.crypto.MessageDigest;
-import org.junit.Test;
-
-import java.security.GeneralSecurityException;
-import java.util.Random;
-
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
-
-public class ConsumersTest extends BriarTestCase {
-
-	@Test
-	public void testDigestingConsumer() throws Exception {
-		byte[] data = new byte[1234];
-		// Generate some random data and digest it
-		new Random().nextBytes(data);
-		MessageDigest messageDigest = new TestMessageDigest();
-		messageDigest.update(data);
-		byte[] dig = messageDigest.digest();
-		// Check that feeding a DigestingConsumer generates the same digest
-		DigestingConsumer dc = new DigestingConsumer(messageDigest);
-		dc.write(data[0]);
-		dc.write(data, 1, data.length - 2);
-		dc.write(data[data.length - 1]);
-		byte[] dig1 = messageDigest.digest();
-		assertArrayEquals(dig, dig1);
-	}
-
-	@Test(expected = FormatException.class)
-	public void testCountingConsumer() throws Exception {
-		byte[] data = new byte[1234];
-		CountingConsumer cc = new CountingConsumer(data.length);
-		cc.write(data[0]);
-		cc.write(data, 1, data.length - 2);
-		cc.write(data[data.length - 1]);
-		assertEquals(data.length, cc.getCount());
-		cc.write((byte) 0);
-	}
-
-	@Test
-	public void testCopyingConsumer() throws Exception {
-		byte[] data = new byte[1234];
-		new Random().nextBytes(data);
-		// Check that a CopyingConsumer creates a faithful copy
-		CopyingConsumer cc = new CopyingConsumer();
-		cc.write(data[0]);
-		cc.write(data, 1, data.length - 2);
-		cc.write(data[data.length - 1]);
-		assertArrayEquals(data, cc.getCopy());
-	}
-
-	private static class TestMessageDigest implements MessageDigest {
-
-		private final java.security.MessageDigest delegate;
-
-		private TestMessageDigest() throws GeneralSecurityException {
-			delegate = java.security.MessageDigest.getInstance("SHA-256");
-		}
-
-		public byte[] digest() {
-			return delegate.digest();
-		}
-
-		public byte[] digest(byte[] input) {
-			return delegate.digest(input);
-		}
-
-		public int digest(byte[] buf, int offset, int len) {
-			byte[] digest = digest();
-			len = Math.min(len, digest.length);
-			System.arraycopy(digest, 0, buf, offset, len);
-			return len;
-		}
-
-		public int getDigestLength() {
-			return delegate.getDigestLength();
-		}
-
-		public void reset() {
-			delegate.reset();
-		}
-
-		public void update(byte input) {
-			delegate.update(input);
-		}
-
-		public void update(byte[] input) {
-			delegate.update(input);
-		}
-
-		public void update(byte[] input, int offset, int len) {
-			delegate.update(input, offset, len);
-		}
-	}
-}
diff --git a/briar-tests/src/org/briarproject/sync/PacketReaderImplTest.java b/briar-tests/src/org/briarproject/sync/PacketReaderImplTest.java
index 194fe2006663ff6cffe612e2f979204ee93c6f16..7224f5b6e28263f03d66a84c10d317af5d26531d 100644
--- a/briar-tests/src/org/briarproject/sync/PacketReaderImplTest.java
+++ b/briar-tests/src/org/briarproject/sync/PacketReaderImplTest.java
@@ -1,50 +1,29 @@
 package org.briarproject.sync;
 
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-
 import org.briarproject.BriarTestCase;
 import org.briarproject.TestUtils;
 import org.briarproject.api.FormatException;
-import org.briarproject.api.data.BdfReaderFactory;
-import org.briarproject.api.data.BdfWriter;
-import org.briarproject.api.data.BdfWriterFactory;
-import org.briarproject.data.DataModule;
+import org.briarproject.api.UniqueId;
 import org.briarproject.util.ByteUtils;
 import org.junit.Test;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 
-import static org.briarproject.api.data.DataConstants.LIST_END_LENGTH;
-import static org.briarproject.api.data.DataConstants.UNIQUE_ID_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.HEADER_LENGTH;
-import static org.briarproject.api.sync.MessagingConstants.MAX_PAYLOAD_LENGTH;
 import static org.briarproject.api.sync.PacketTypes.ACK;
 import static org.briarproject.api.sync.PacketTypes.OFFER;
 import static org.briarproject.api.sync.PacketTypes.REQUEST;
+import static org.briarproject.api.sync.SyncConstants.MAX_PACKET_PAYLOAD_LENGTH;
+import static org.briarproject.api.sync.SyncConstants.PACKET_HEADER_LENGTH;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
 
 public class PacketReaderImplTest extends BriarTestCase {
 
-	// FIXME: This is an integration test, not a unit test
-
-	private final BdfReaderFactory bdfReaderFactory;
-	private final BdfWriterFactory bdfWriterFactory;
-
-	public PacketReaderImplTest() throws Exception {
-		Injector i = Guice.createInjector(new DataModule());
-		bdfReaderFactory = i.getInstance(BdfReaderFactory.class);
-		bdfWriterFactory = i.getInstance(BdfWriterFactory.class);
-	}
-
 	@Test(expected = FormatException.class)
 	public void testFormatExceptionIfAckIsTooLarge() throws Exception {
 		byte[] b = createAck(true);
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readAck();
 	}
 
@@ -52,8 +31,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testNoFormatExceptionIfAckIsMaximumSize() throws Exception {
 		byte[] b = createAck(false);
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readAck();
 	}
 
@@ -61,8 +39,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testEmptyAck() throws Exception {
 		byte[] b = createEmptyAck();
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readAck();
 	}
 
@@ -70,8 +47,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testFormatExceptionIfOfferIsTooLarge() throws Exception {
 		byte[] b = createOffer(true);
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readOffer();
 	}
 
@@ -79,10 +55,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testNoFormatExceptionIfOfferIsMaximumSize() throws Exception {
 		byte[] b = createOffer(false);
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl
-				reader = new PacketReaderImpl(
-				bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readOffer();
 	}
 
@@ -90,8 +63,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testEmptyOffer() throws Exception {
 		byte[] b = createEmptyOffer();
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readOffer();
 	}
 
@@ -99,8 +71,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testFormatExceptionIfRequestIsTooLarge() throws Exception {
 		byte[] b = createRequest(true);
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readRequest();
 	}
 
@@ -108,8 +79,7 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testNoFormatExceptionIfRequestIsMaximumSize() throws Exception {
 		byte[] b = createRequest(false);
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readRequest();
 	}
 
@@ -117,110 +87,76 @@ public class PacketReaderImplTest extends BriarTestCase {
 	public void testEmptyRequest() throws Exception {
 		byte[] b = createEmptyRequest();
 		ByteArrayInputStream in = new ByteArrayInputStream(b);
-		PacketReaderImpl reader = new PacketReaderImpl(bdfReaderFactory, null,
-				null, in);
+		PacketReaderImpl reader = new PacketReaderImpl(null, null, null, in);
 		reader.readRequest();
 	}
 
 	private byte[] createAck(boolean tooBig) throws Exception {
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		out.write(new byte[HEADER_LENGTH]);
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		w.writeListStart();
-		w.writeListStart();
-		while (out.size() + UNIQUE_ID_LENGTH + LIST_END_LENGTH * 2
-				< HEADER_LENGTH + MAX_PAYLOAD_LENGTH) {
-			w.writeRaw(TestUtils.getRandomId());
+		out.write(new byte[PACKET_HEADER_LENGTH]);
+		while (out.size() + UniqueId.LENGTH <= PACKET_HEADER_LENGTH
+				+ MAX_PACKET_PAYLOAD_LENGTH) {
+			out.write(TestUtils.getRandomId());
 		}
-		if (tooBig) w.writeRaw(TestUtils.getRandomId());
-		w.writeListEnd();
-		w.writeListEnd();
-		assertEquals(tooBig, out.size() > HEADER_LENGTH + MAX_PAYLOAD_LENGTH);
+		if (tooBig) out.write(TestUtils.getRandomId());
+		assertEquals(tooBig, out.size() > PACKET_HEADER_LENGTH +
+				MAX_PACKET_PAYLOAD_LENGTH);
 		byte[] packet = out.toByteArray();
 		packet[1] = ACK;
-		ByteUtils.writeUint16(packet.length - HEADER_LENGTH, packet, 2);
+		ByteUtils.writeUint16(packet.length - PACKET_HEADER_LENGTH, packet, 2);
 		return packet;
 	}
 
 	private byte[] createEmptyAck() throws Exception {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		out.write(new byte[HEADER_LENGTH]);
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		w.writeListStart();
-		w.writeListStart();
-		w.writeListEnd();
-		w.writeListEnd();
-		byte[] packet = out.toByteArray();
+		byte[] packet = new byte[PACKET_HEADER_LENGTH];
 		packet[1] = ACK;
-		ByteUtils.writeUint16(packet.length - HEADER_LENGTH, packet, 2);
+		ByteUtils.writeUint16(packet.length - PACKET_HEADER_LENGTH, packet, 2);
 		return packet;
 	}
 
 	private byte[] createOffer(boolean tooBig) throws Exception {
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		out.write(new byte[HEADER_LENGTH]);
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		w.writeListStart();
-		w.writeListStart();
-		while (out.size() + UNIQUE_ID_LENGTH + LIST_END_LENGTH * 2
-				< HEADER_LENGTH + MAX_PAYLOAD_LENGTH) {
-			w.writeRaw(TestUtils.getRandomId());
+		out.write(new byte[PACKET_HEADER_LENGTH]);
+		while (out.size() + UniqueId.LENGTH <= PACKET_HEADER_LENGTH
+				+ MAX_PACKET_PAYLOAD_LENGTH) {
+			out.write(TestUtils.getRandomId());
 		}
-		if (tooBig) w.writeRaw(TestUtils.getRandomId());
-		w.writeListEnd();
-		w.writeListEnd();
-		assertEquals(tooBig, out.size() > HEADER_LENGTH + MAX_PAYLOAD_LENGTH);
+		if (tooBig) out.write(TestUtils.getRandomId());
+		assertEquals(tooBig, out.size() > PACKET_HEADER_LENGTH +
+				MAX_PACKET_PAYLOAD_LENGTH);
 		byte[] packet = out.toByteArray();
 		packet[1] = OFFER;
-		ByteUtils.writeUint16(packet.length - HEADER_LENGTH, packet, 2);
+		ByteUtils.writeUint16(packet.length - PACKET_HEADER_LENGTH, packet, 2);
 		return packet;
 	}
 
 	private byte[] createEmptyOffer() throws Exception {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		out.write(new byte[HEADER_LENGTH]);
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		w.writeListStart();
-		w.writeListStart();
-		w.writeListEnd();
-		w.writeListEnd();
-		byte[] packet = out.toByteArray();
+		byte[] packet = new byte[PACKET_HEADER_LENGTH];
 		packet[1] = OFFER;
-		ByteUtils.writeUint16(packet.length - HEADER_LENGTH, packet, 2);
+		ByteUtils.writeUint16(packet.length - PACKET_HEADER_LENGTH, packet, 2);
 		return packet;
 	}
 
 	private byte[] createRequest(boolean tooBig) throws Exception {
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		out.write(new byte[HEADER_LENGTH]);
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		w.writeListStart();
-		w.writeListStart();
-		while (out.size() + UNIQUE_ID_LENGTH + LIST_END_LENGTH * 2
-				< HEADER_LENGTH + MAX_PAYLOAD_LENGTH) {
-			w.writeRaw(TestUtils.getRandomId());
+		out.write(new byte[PACKET_HEADER_LENGTH]);
+		while (out.size() + UniqueId.LENGTH <= PACKET_HEADER_LENGTH
+				+ MAX_PACKET_PAYLOAD_LENGTH) {
+			out.write(TestUtils.getRandomId());
 		}
-		if (tooBig) w.writeRaw(TestUtils.getRandomId());
-		w.writeListEnd();
-		w.writeListEnd();
-		assertEquals(tooBig, out.size() > HEADER_LENGTH + MAX_PAYLOAD_LENGTH);
+		if (tooBig) out.write(TestUtils.getRandomId());
+		assertEquals(tooBig, out.size() > PACKET_HEADER_LENGTH +
+				MAX_PACKET_PAYLOAD_LENGTH);
 		byte[] packet = out.toByteArray();
 		packet[1] = REQUEST;
-		ByteUtils.writeUint16(packet.length - HEADER_LENGTH, packet, 2);
+		ByteUtils.writeUint16(packet.length - PACKET_HEADER_LENGTH, packet, 2);
 		return packet;
 	}
 
 	private byte[] createEmptyRequest() throws Exception {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		out.write(new byte[HEADER_LENGTH]);
-		BdfWriter w = bdfWriterFactory.createWriter(out);
-		w.writeListStart();
-		w.writeListStart();
-		w.writeListEnd();
-		w.writeListEnd();
-		byte[] packet = out.toByteArray();
+		byte[] packet = new byte[PACKET_HEADER_LENGTH];
 		packet[1] = REQUEST;
-		ByteUtils.writeUint16(packet.length - HEADER_LENGTH, packet, 2);
+		ByteUtils.writeUint16(packet.length - PACKET_HEADER_LENGTH, packet, 2);
 		return packet;
 	}
 }
diff --git a/briar-tests/src/org/briarproject/sync/SimplexMessagingIntegrationTest.java b/briar-tests/src/org/briarproject/sync/SimplexMessagingIntegrationTest.java
index 88ae60a383c9c83b4ccfce212c127b58d4269444..3a7be3b74b4254f3299a1bbb75a2cd878f3fdf02 100644
--- a/briar-tests/src/org/briarproject/sync/SimplexMessagingIntegrationTest.java
+++ b/briar-tests/src/org/briarproject/sync/SimplexMessagingIntegrationTest.java
@@ -22,16 +22,14 @@ import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.messaging.MessagingManager;
-import org.briarproject.api.messaging.PrivateConversation;
+import org.briarproject.api.messaging.PrivateMessage;
 import org.briarproject.api.messaging.PrivateMessageFactory;
 import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.Message;
-import org.briarproject.api.sync.MessageVerifier;
-import org.briarproject.api.sync.MessagingSession;
 import org.briarproject.api.sync.PacketReader;
 import org.briarproject.api.sync.PacketReaderFactory;
 import org.briarproject.api.sync.PacketWriter;
 import org.briarproject.api.sync.PacketWriterFactory;
+import org.briarproject.api.sync.SyncSession;
 import org.briarproject.api.transport.KeyManager;
 import org.briarproject.api.transport.StreamContext;
 import org.briarproject.api.transport.StreamReaderFactory;
@@ -73,6 +71,8 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 	private final TransportId transportId = new TransportId("id");
 	private final SecretKey master = TestUtils.createSecretKey();
 	private final long timestamp = System.currentTimeMillis();
+	private final AuthorId aliceId = new AuthorId(TestUtils.getRandomId());
+	private final AuthorId bobId = new AuthorId(TestUtils.getRandomId());
 
 	private Injector alice, bob;
 
@@ -106,14 +106,12 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		KeyManager keyManager = alice.getInstance(KeyManager.class);
 		keyManager.start();
 		// Add an identity for Alice
-		AuthorId aliceId = new AuthorId(TestUtils.getRandomId());
 		LocalAuthor aliceAuthor = new LocalAuthor(aliceId, "Alice",
-				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100], 1234);
+				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100], timestamp);
 		IdentityManager identityManager =
 				alice.getInstance(IdentityManager.class);
 		identityManager.addLocalAuthor(aliceAuthor);
 		// Add Bob as a contact
-		AuthorId bobId = new AuthorId(TestUtils.getRandomId());
 		Author bobAuthor = new Author(bobId, "Bob",
 				new byte[MAX_PUBLIC_KEY_LENGTH]);
 		ContactManager contactManager = alice.getInstance(ContactManager.class);
@@ -121,19 +119,17 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		// Create the private conversation
 		MessagingManager messagingManager =
 				alice.getInstance(MessagingManager.class);
-		messagingManager.addContact(contactId, master);
+		messagingManager.addContact(contactId);
 		// Derive and store the transport keys
 		keyManager.addContact(contactId, Collections.singletonList(transportId),
 				master, timestamp, true);
 		// Send Bob a message
-		byte[] body = "Hi Bob!".getBytes("UTF-8");
-		PrivateMessageFactory messageFactory =
+		PrivateMessageFactory privateMessageFactory =
 				alice.getInstance(PrivateMessageFactory.class);
 		GroupId groupId = messagingManager.getConversationId(contactId);
-		PrivateConversation conversation =
-				messagingManager.getConversation(groupId);
-		Message message = messageFactory.createPrivateMessage(null,
-				conversation, "text/plain", timestamp, body);
+		byte[] body = "Hi Bob!".getBytes("UTF-8");
+		PrivateMessage message = privateMessageFactory.createPrivateMessage(
+				groupId, timestamp, null, "text/plain", body);
 		messagingManager.addLocalMessage(message);
 		// Get a stream context
 		StreamContext ctx = keyManager.getStreamContext(contactId, transportId);
@@ -150,7 +146,7 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 				alice.getInstance(PacketWriterFactory.class);
 		PacketWriter packetWriter = packetWriterFactory.createPacketWriter(
 				streamWriter);
-		MessagingSession session = new SimplexOutgoingSession(db,
+		SyncSession session = new SimplexOutgoingSession(db,
 				new ImmediateExecutor(), eventBus, contactId, transportId,
 				MAX_LATENCY, packetWriter);
 		// Write whatever needs to be written
@@ -173,14 +169,12 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		KeyManager keyManager = bob.getInstance(KeyManager.class);
 		keyManager.start();
 		// Add an identity for Bob
-		AuthorId bobId = new AuthorId(TestUtils.getRandomId());
 		LocalAuthor bobAuthor = new LocalAuthor(bobId, "Bob",
-				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100], 1234);
+				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100], timestamp);
 		IdentityManager identityManager =
 				bob.getInstance(IdentityManager.class);
 		identityManager.addLocalAuthor(bobAuthor);
 		// Add Alice as a contact
-		AuthorId aliceId = new AuthorId(TestUtils.getRandomId());
 		Author aliceAuthor = new Author(aliceId, "Alice",
 				new byte[MAX_PUBLIC_KEY_LENGTH]);
 		ContactManager contactManager = bob.getInstance(ContactManager.class);
@@ -188,7 +182,7 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		// Create the private conversation
 		MessagingManager messagingManager =
 				bob.getInstance(MessagingManager.class);
-		messagingManager.addContact(contactId, master);
+		messagingManager.addContact(contactId);
 		// Derive and store the transport keys
 		keyManager.addContact(contactId, Collections.singletonList(transportId),
 				master, timestamp, false);
@@ -209,15 +203,13 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 				streamReaderFactory.createStreamReader(in, ctx);
 		// Create an incoming sync session
 		EventBus eventBus = bob.getInstance(EventBus.class);
-		MessageVerifier messageVerifier =
-				bob.getInstance(MessageVerifier.class);
 		PacketReaderFactory packetReaderFactory =
 				bob.getInstance(PacketReaderFactory.class);
 		PacketReader packetReader = packetReaderFactory.createPacketReader(
 				streamReader);
-		MessagingSession session = new IncomingSession(db,
-				new ImmediateExecutor(), new ImmediateExecutor(), eventBus,
-				messageVerifier, contactId, transportId, packetReader);
+		SyncSession session = new IncomingSession(db,
+				new ImmediateExecutor(), eventBus, contactId, transportId,
+				packetReader);
 		// No messages should have been added yet
 		assertFalse(listener.messageAdded);
 		// Read whatever needs to be read
diff --git a/briar-tests/src/org/briarproject/util/ByteUtilsTest.java b/briar-tests/src/org/briarproject/util/ByteUtilsTest.java
index 78843631dcde79bcbc28fb2bb66d8d3f440abae1..11919c5fc60fcd1d270c534b8ea5849d70260584 100644
--- a/briar-tests/src/org/briarproject/util/ByteUtilsTest.java
+++ b/briar-tests/src/org/briarproject/util/ByteUtilsTest.java
@@ -12,11 +12,13 @@ public class ByteUtilsTest extends BriarTestCase {
 
 	@Test
 	public void testReadUint16() {
-		byte[] b = StringUtils.fromHexString("000000");
+		byte[] b = StringUtils.fromHexString("00000000");
 		assertEquals(0, ByteUtils.readUint16(b, 1));
-		b = StringUtils.fromHexString("000001");
+		b = StringUtils.fromHexString("00000100");
 		assertEquals(1, ByteUtils.readUint16(b, 1));
-		b = StringUtils.fromHexString("00FFFF");
+		b = StringUtils.fromHexString("007FFF00");
+		assertEquals(Short.MAX_VALUE, ByteUtils.readUint16(b, 1));
+		b = StringUtils.fromHexString("00FFFF00");
 		assertEquals(65535, ByteUtils.readUint16(b, 1));
 	}
 
@@ -32,11 +34,13 @@ public class ByteUtilsTest extends BriarTestCase {
 
 	@Test
 	public void testReadUint32() {
-		byte[] b = StringUtils.fromHexString("0000000000");
+		byte[] b = StringUtils.fromHexString("000000000000");
 		assertEquals(0, ByteUtils.readUint32(b, 1));
-		b = StringUtils.fromHexString("0000000001");
+		b = StringUtils.fromHexString("000000000100");
 		assertEquals(1, ByteUtils.readUint32(b, 1));
-		b = StringUtils.fromHexString("00FFFFFFFF");
+		b = StringUtils.fromHexString("007FFFFFFF00");
+		assertEquals(Integer.MAX_VALUE, ByteUtils.readUint32(b, 1));
+		b = StringUtils.fromHexString("00FFFFFFFF00");
 		assertEquals(4294967295L, ByteUtils.readUint32(b, 1));
 	}
 
@@ -50,6 +54,30 @@ public class ByteUtilsTest extends BriarTestCase {
 		ByteUtils.readUint32(new byte[4], 1);
 	}
 
+	@Test
+	public void testReadUint64() {
+		byte[] b = StringUtils.fromHexString("00000000000000000000");
+		assertEquals(0L, ByteUtils.readUint64(b, 1));
+		b = StringUtils.fromHexString("00000000000000000100");
+		assertEquals(1L, ByteUtils.readUint64(b, 1));
+		b = StringUtils.fromHexString("007FFFFFFFFFFFFFFF00");
+		assertEquals(Long.MAX_VALUE, ByteUtils.readUint64(b, 1));
+		b = StringUtils.fromHexString("00800000000000000000");
+		assertEquals(Long.MIN_VALUE, ByteUtils.readUint64(b, 1));
+		b = StringUtils.fromHexString("00FFFFFFFFFFFFFFFF00");
+		assertEquals(-1L, ByteUtils.readUint64(b, 1));
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void testReadUint64ValidatesArguments1() {
+		ByteUtils.readUint64(new byte[7], 0);
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void testReadUint64ValidatesArguments2() {
+		ByteUtils.readUint64(new byte[8], 1);
+	}
+
 	@Test
 	public void testWriteUint16() {
 		byte[] b = new byte[4];