diff --git a/api/net/sf/briar/api/db/DatabaseComponent.java b/api/net/sf/briar/api/db/DatabaseComponent.java
index dd231be4d440e57730430175c3662ed07e2e7178..f662e85b606053ca5cd0b81ea22b766bedb655e9 100644
--- a/api/net/sf/briar/api/db/DatabaseComponent.java
+++ b/api/net/sf/briar/api/db/DatabaseComponent.java
@@ -1,5 +1,7 @@
 package net.sf.briar.api.db;
 
+import java.io.IOException;
+import java.security.SignatureException;
 import java.util.Map;
 import java.util.Set;
 
@@ -49,7 +51,7 @@ public interface DatabaseComponent {
 	 * Generates a bundle of acknowledgements, subscriptions, and batches of
 	 * messages for the given contact.
 	 */
-	Bundle generateBundle(ContactId c, BundleBuilder bundleBuilder) throws DbException;
+	Bundle generateBundle(ContactId c, BundleBuilder bundleBuilder) throws DbException, IOException, SignatureException;
 
 	/** Returns the IDs of all contacts. */
 	Set<ContactId> getContacts() throws DbException;
@@ -71,7 +73,7 @@ public interface DatabaseComponent {
 	 * messages received from the given contact. Some or all of the messages
 	 * in the bundle may be stored.
 	 */
-	void receiveBundle(ContactId c, Bundle b) throws DbException;
+	void receiveBundle(ContactId c, Bundle b) throws DbException, IOException, SignatureException;
 
 	/** Removes a contact (and all associated state) from the database. */
 	void removeContact(ContactId c) throws DbException;
diff --git a/api/net/sf/briar/api/protocol/Batch.java b/api/net/sf/briar/api/protocol/Batch.java
index eaa0ec261892a68a4f193cc2016bf238a96c1772..05dc321a295bc3bc004124a4fd9188951a99cbb7 100644
--- a/api/net/sf/briar/api/protocol/Batch.java
+++ b/api/net/sf/briar/api/protocol/Batch.java
@@ -1,16 +1,19 @@
 package net.sf.briar.api.protocol;
 
-/** A batch of messages up to CAPACITY bytes in total size. */
+/** A batch of messages up to MAX_SIZE bytes in total size. */
 public interface Batch {
 
-	public static final long CAPACITY = 1024L * 1024L;
+	public static final int MAX_SIZE = 1024 * 1024;
 
 	/** Returns the batch's unique identifier. */
 	BatchId getId();
 
-	/** Returns the size of the batch in bytes. */
+	/** Returns the size of the serialised batch in bytes. */
 	long getSize();
 
 	/** Returns the messages contained in the batch. */
 	Iterable<Message> getMessages();
+
+	/** Returns the sender's signature over the contents of the batch. */
+	byte[] getSignature();
 }
\ No newline at end of file
diff --git a/api/net/sf/briar/api/protocol/BatchBuilder.java b/api/net/sf/briar/api/protocol/BatchBuilder.java
index cd5c345510bd2093b452eeb73c12399cf5d6dcbe..992657c9a98f3c6fd49f77dc375c787e238a211c 100644
--- a/api/net/sf/briar/api/protocol/BatchBuilder.java
+++ b/api/net/sf/briar/api/protocol/BatchBuilder.java
@@ -1,10 +1,15 @@
 package net.sf.briar.api.protocol;
 
+import java.security.SignatureException;
+
 public interface BatchBuilder {
 
 	/** Adds a message to the batch. */
 	void addMessage(Message m);
 
+	/** Sets the sender's signature over the contents of the batch. */
+	void setSignature(byte[] sig);
+
 	/** Builds and returns the batch. */
-	Batch build();
+	Batch build() throws SignatureException;
 }
diff --git a/api/net/sf/briar/api/protocol/Bundle.java b/api/net/sf/briar/api/protocol/Bundle.java
index 7465c3b56e27debc2e484c2ae7337b5d3656cafb..82ff0f86d906ebe3d97157aad4ae584ea16c3ea5 100644
--- a/api/net/sf/briar/api/protocol/Bundle.java
+++ b/api/net/sf/briar/api/protocol/Bundle.java
@@ -1,28 +1,21 @@
 package net.sf.briar.api.protocol;
 
-import java.util.Map;
+import java.io.IOException;
+import java.security.SignatureException;
 
-/** A bundle of acknowledgements, subscriptions, and batches of messages. */
+/**
+ * A bundle of acknowledgements, subscriptions, transport details and batches.
+ */
 public interface Bundle {
 
-	/** Returns the bundle's unique identifier. */
-	BundleId getId();
+	/** Returns the size of the serialised bundle in bytes. */
+	long getSize() throws IOException;
 
-	/** Returns the bundle's capacity in bytes. */
-	long getCapacity();
+	/** Returns the bundle's header. */
+	Header getHeader() throws IOException, SignatureException;
 
-	/** Returns the bundle's size in bytes. */
-	long getSize();
-
-	/** Returns the acknowledgements contained in the bundle. */
-	Iterable<BatchId> getAcks();
-
-	/** Returns the subscriptions contained in the bundle. */
-	Iterable<GroupId> getSubscriptions();
-
-	/** Returns the transport details contained in the bundle. */
-	Map<String, String> getTransports();
-
-	/** Returns the batches of messages contained in the bundle. */
-	Iterable<Batch> getBatches();
+	/**
+	 * Returns the next batch of messages, or null if there are no more batches.
+	 */
+	Batch getNextBatch() throws IOException, SignatureException;
 }
diff --git a/api/net/sf/briar/api/protocol/BundleBuilder.java b/api/net/sf/briar/api/protocol/BundleBuilder.java
index f95ef602dc9356c61ef394471b095e8ae5fa47ed..7f7c837719c4d87d07b46ec1d685f734e5608ed9 100644
--- a/api/net/sf/briar/api/protocol/BundleBuilder.java
+++ b/api/net/sf/briar/api/protocol/BundleBuilder.java
@@ -1,22 +1,18 @@
 package net.sf.briar.api.protocol;
 
+import java.io.IOException;
+
 public interface BundleBuilder {
 
 	/** Returns the bundle's capacity in bytes. */
-	long getCapacity();
-
-	/** Adds an acknowledgement to the bundle. */
-	void addAck(BatchId b);
-
-	/** Adds a subscription to the bundle. */
-	void addSubscription(GroupId g);
+	long getCapacity() throws IOException;
 
-	/** Adds a transport detail to the bundle. */
-	void addTransport(String key, String value);
+	/** Adds a header to the bundle. */
+	void addHeader(Header h) throws IOException;
 
 	/** Adds a batch of messages to the bundle. */
-	void addBatch(Batch b);
+	void addBatch(Batch b) throws IOException;
 
 	/** Builds and returns the bundle. */
-	Bundle build();
+	Bundle build() throws IOException;
 }
diff --git a/api/net/sf/briar/api/protocol/Header.java b/api/net/sf/briar/api/protocol/Header.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce3c581a0740198eed30475c0bf47ece526ee6a0
--- /dev/null
+++ b/api/net/sf/briar/api/protocol/Header.java
@@ -0,0 +1,28 @@
+package net.sf.briar.api.protocol;
+
+import java.util.Map;
+import java.util.Set;
+
+/** A bundle header up to MAX_SIZE bytes in total size. */
+public interface Header {
+
+	static final int MAX_SIZE = 1024 * 1024;
+
+	// FIXME: Remove BundleId when refactoring is complete
+	BundleId getId();
+
+	/** Returns the size of the serialised header in bytes. */
+	long getSize();
+
+	/** Returns the acknowledgements contained in the header. */
+	Set<BatchId> getAcks();
+
+	/** Returns the subscriptions contained in the header. */
+	Set<GroupId> getSubscriptions();
+
+	/** Returns the transport details contained in the header. */
+	Map<String, String> getTransports();
+
+	/** Returns the sender's signature over the contents of the header. */
+	byte[] getSignature();
+}
diff --git a/api/net/sf/briar/api/protocol/HeaderBuilder.java b/api/net/sf/briar/api/protocol/HeaderBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..4c00f8218fab97ecee1e018b94b98c7eba1f6ad7
--- /dev/null
+++ b/api/net/sf/briar/api/protocol/HeaderBuilder.java
@@ -0,0 +1,24 @@
+package net.sf.briar.api.protocol;
+
+import java.io.IOException;
+import java.security.SignatureException;
+import java.util.Map;
+import java.util.Set;
+
+public interface HeaderBuilder {
+
+	/** Adds acknowledgements to the header. */
+	void addAcks(Set<BatchId> acks) throws IOException;
+
+	/** Adds subscriptions to the header. */
+	void addSubscriptions(Set<GroupId> subs) throws IOException;
+
+	/** Adds transport details to the header. */
+	void addTransports(Map<String, String> transports) throws IOException;
+
+	/** Sets the sender's signature over the contents of the header. */
+	void setSignature(byte[] sig) throws IOException;
+
+	/** Builds and returns the header. */
+	Header build() throws SignatureException;
+}
diff --git a/api/net/sf/briar/api/protocol/MessageParser.java b/api/net/sf/briar/api/protocol/MessageParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..95a44e4cdd3528501098983de80ef60e31747a21
--- /dev/null
+++ b/api/net/sf/briar/api/protocol/MessageParser.java
@@ -0,0 +1,10 @@
+package net.sf.briar.api.protocol;
+
+import java.security.SignatureException;
+
+import net.sf.briar.api.serial.FormatException;
+
+public interface MessageParser {
+
+	Message parseMessage(byte[] body) throws FormatException, SignatureException;
+}
diff --git a/api/net/sf/briar/api/protocol/UniqueId.java b/api/net/sf/briar/api/protocol/UniqueId.java
index 3eb9cdd15d8eb4d9beaaf620d6a903a699c62c91..22f57dfe2b80f5a093bc859abc48d5dfe6fca04c 100644
--- a/api/net/sf/briar/api/protocol/UniqueId.java
+++ b/api/net/sf/briar/api/protocol/UniqueId.java
@@ -2,7 +2,9 @@ package net.sf.briar.api.protocol;
 
 import java.util.Arrays;
 
-public abstract class UniqueId {
+import net.sf.briar.api.serial.Raw;
+
+public abstract class UniqueId implements Raw {
 
 	public static final int LENGTH = 32;
 
diff --git a/components/net/sf/briar/db/DatabaseComponentImpl.java b/components/net/sf/briar/db/DatabaseComponentImpl.java
index 49427ce337d991e3917380e6b8b7d06b6a4c2e6c..4fc737634d40e8abcced69301854d4d57e8663e0 100644
--- a/components/net/sf/briar/db/DatabaseComponentImpl.java
+++ b/components/net/sf/briar/db/DatabaseComponentImpl.java
@@ -10,6 +10,7 @@ import net.sf.briar.api.db.DbException;
 import net.sf.briar.api.db.Status;
 import net.sf.briar.api.protocol.AuthorId;
 import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.HeaderBuilder;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageId;
 
@@ -27,6 +28,7 @@ DatabaseCleaner.Callback {
 
 	protected final Database<Txn> db;
 	protected final DatabaseCleaner cleaner;
+	protected final Provider<HeaderBuilder> headerBuilderProvider;
 	protected final Provider<BatchBuilder> batchBuilderProvider;
 
 	private final Object spaceLock = new Object();
@@ -36,9 +38,11 @@ DatabaseCleaner.Callback {
 	private volatile boolean writesAllowed = true;
 
 	DatabaseComponentImpl(Database<Txn> db, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
 		this.db = db;
 		this.cleaner = cleaner;
+		this.headerBuilderProvider = headerBuilderProvider;
 		this.batchBuilderProvider = batchBuilderProvider;
 	}
 
diff --git a/components/net/sf/briar/db/ReadWriteLockDatabaseComponent.java b/components/net/sf/briar/db/ReadWriteLockDatabaseComponent.java
index a5ec477985954221a7442c30ebcb27d5bd3ff1d3..8ab59f9335aeac53bf75a3565e3ef09f08e56706 100644
--- a/components/net/sf/briar/db/ReadWriteLockDatabaseComponent.java
+++ b/components/net/sf/briar/db/ReadWriteLockDatabaseComponent.java
@@ -1,9 +1,10 @@
 package net.sf.briar.db;
 
+import java.io.IOException;
+import java.security.SignatureException;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.logging.Level;
@@ -20,6 +21,8 @@ import net.sf.briar.api.protocol.BatchId;
 import net.sf.briar.api.protocol.Bundle;
 import net.sf.briar.api.protocol.BundleBuilder;
 import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.HeaderBuilder;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageId;
 
@@ -55,8 +58,9 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 
 	@Inject
 	ReadWriteLockDatabaseComponent(Database<Txn> db, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
-		super(db, cleaner, batchBuilderProvider);
+		super(db, cleaner, headerBuilderProvider, batchBuilderProvider);
 	}
 
 	public void close() throws DbException {
@@ -187,23 +191,22 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 	}
 
 	public Bundle generateBundle(ContactId c, BundleBuilder b)
-	throws DbException {
+	throws DbException, IOException, SignatureException {
 		if(LOG.isLoggable(Level.FINE)) LOG.fine("Generating bundle for " + c);
-		// Ack all batches received from c
+		HeaderBuilder h;
+		// Add acks
 		contactLock.readLock().lock();
 		try {
 			if(!containsContact(c)) throw new NoSuchContactException();
+			h = headerBuilderProvider.get();
 			messageStatusLock.writeLock().lock();
 			try {
 				Txn txn = db.startTransaction();
 				try {
-					int numAcks = 0;
-					for(BatchId ack : db.removeBatchesToAck(txn, c)) {
-						b.addAck(ack);
-						numAcks++;
-					}
+					Set<BatchId> acks = db.removeBatchesToAck(txn, c);
+					h.addAcks(acks);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Added " + numAcks + " acks");
+						LOG.fine("Added " + acks.size() + " acks");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -215,7 +218,7 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		// Add a list of subscriptions
+		// Add subscriptions
 		contactLock.readLock().lock();
 		try {
 			if(!containsContact(c)) throw new NoSuchContactException();
@@ -223,13 +226,10 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			try {
 				Txn txn = db.startTransaction();
 				try {
-					int numSubs = 0;
-					for(GroupId g : db.getSubscriptions(txn)) {
-						b.addSubscription(g);
-						numSubs++;
-					}
+					Set<GroupId> subs = db.getSubscriptions(txn);
+					h.addSubscriptions(subs);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Added " + numSubs + " subscriptions");
+						LOG.fine("Added " + subs.size() + " subscriptions");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -249,14 +249,10 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			try {
 				Txn txn = db.startTransaction();
 				try {
-					int numTransports = 0;
 					Map<String, String> transports = db.getTransports(txn);
-					for(Entry<String, String> e : transports.entrySet()) {
-						b.addTransport(e.getKey(), e.getValue());
-						numTransports++;
-					}
+					h.addTransports(transports);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Added " + numTransports + " transports");
+						LOG.fine("Added " + transports.size() + " transports");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -268,8 +264,12 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		// Add as many messages as possible to the bundle
+		// Sign the header and add it to the bundle
+		Header header = h.build();
 		long capacity = b.getCapacity();
+		capacity -= header.getSize();
+		b.addHeader(header);
+		// Add as many messages as possible to the bundle
 		while(true) {
 			Batch batch = fillBatch(c, capacity);
 			if(batch == null) break; // No more messages to send
@@ -278,7 +278,7 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			capacity -= size;
 			// If the batch is less than half full, stop trying - there may be
 			// more messages trickling in but we can't wait forever
-			if(size * 2 < Batch.CAPACITY) break;
+			if(size * 2 < Batch.MAX_SIZE) break;
 		}
 		Bundle bundle = b.build();
 		if(LOG.isLoggable(Level.FINE))
@@ -287,20 +287,20 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		return bundle;
 	}
 
-	private Batch fillBatch(ContactId c, long capacity) throws DbException {
+	private Batch fillBatch(ContactId c, long capacity) throws DbException,
+	SignatureException {
 		contactLock.readLock().lock();
 		try {
 			if(!containsContact(c)) throw new NoSuchContactException();
 			messageLock.readLock().lock();
 			try {
 				Set<MessageId> sent;
-				BatchBuilder b;
 				Batch batch;
 				messageStatusLock.readLock().lock();
 				try {
 					Txn txn = db.startTransaction();
 					try {
-						capacity = Math.min(capacity, Batch.CAPACITY);
+						capacity = Math.min(capacity, Batch.MAX_SIZE);
 						Iterator<MessageId> it =
 							db.getSendableMessages(txn, c, capacity).iterator();
 						if(!it.hasNext()) {
@@ -308,7 +308,7 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 							return null; // No more messages to send
 						}
 						sent = new HashSet<MessageId>();
-						b = batchBuilderProvider.get();
+						BatchBuilder b = batchBuilderProvider.get();
 						while(it.hasNext()) {
 							MessageId m = it.next();
 							b.addMessage(db.getMessage(txn, m));
@@ -319,6 +319,9 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 					} catch(DbException e) {
 						db.abortTransaction(txn);
 						throw e;
+					} catch(SignatureException e) {
+						db.abortTransaction(txn);
+						throw e;
 					}
 				} finally {
 					messageStatusLock.readLock().unlock();
@@ -438,21 +441,23 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		}
 	}
 
-	public void receiveBundle(ContactId c, Bundle b) throws DbException {
+	public void receiveBundle(ContactId c, Bundle b) throws DbException,
+	IOException, SignatureException {
 		if(LOG.isLoggable(Level.FINE))
 			LOG.fine("Received bundle from " + c + ", "
 					+ b.getSize() + " bytes");
+		Header h;
 		// Mark all messages in acked batches as seen
 		contactLock.readLock().lock();
 		try {
 			if(!containsContact(c)) throw new NoSuchContactException();
+			h = b.getHeader();
 			messageLock.readLock().lock();
 			try {
 				messageStatusLock.writeLock().lock();
 				try {
-					int acks = 0;
-					for(BatchId ack : b.getAcks()) {
-						acks++;
+					Set<BatchId> acks = h.getAcks();
+					for(BatchId ack : acks) {
 						Txn txn = db.startTransaction();
 						try {
 							db.removeAckedBatch(txn, c, ack);
@@ -463,7 +468,7 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 						}
 					}
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Received " + acks + " acks");
+						LOG.fine("Received " + acks.size() + " acks");
 				} finally {
 					messageStatusLock.writeLock().unlock();
 				}
@@ -481,14 +486,12 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			try {
 				Txn txn = db.startTransaction();
 				try {
+					// FIXME: Replace clearSubs and addSub with setSubs
 					db.clearSubscriptions(txn, c);
-					int subs = 0;
-					for(GroupId g : b.getSubscriptions()) {
-						subs++;
-						db.addSubscription(txn, c, g);
-					}
+					Set<GroupId> subs = h.getSubscriptions();
+					for(GroupId sub : subs) db.addSubscription(txn, c, sub);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Received " + subs + " subscriptions");
+						LOG.fine("Received " + subs.size() + " subscriptions");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -508,7 +511,11 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			try {
 				Txn txn = db.startTransaction();
 				try {
-					db.setTransports(txn, c, b.getTransports());
+					Map<String, String> transports = h.getTransports();
+					db.setTransports(txn, c, transports);
+					if(LOG.isLoggable(Level.FINE))
+						LOG.fine("Received " + transports.size()
+								+ " transports");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -522,7 +529,7 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		}
 		// Store the messages
 		int batches = 0;
-		for(Batch batch : b.getBatches()) {
+		for(Batch batch = b.getNextBatch(); batch != null; batch = b.getNextBatch()) {
 			batches++;
 			waitForPermissionToWrite();
 			contactLock.readLock().lock();
@@ -579,7 +586,7 @@ class ReadWriteLockDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 				try {
 					Txn txn = db.startTransaction();
 					try {
-						lost = db.addReceivedBundle(txn, c, b.getId());
+						lost = db.addReceivedBundle(txn, c, h.getId());
 						db.commitTransaction(txn);
 					} catch(DbException e) {
 						db.abortTransaction(txn);
diff --git a/components/net/sf/briar/db/SynchronizedDatabaseComponent.java b/components/net/sf/briar/db/SynchronizedDatabaseComponent.java
index cacf6cfe9262f987561a922318bea650e77ee57a..df94720028abbcd2c45083acdd5654abc5b54f97 100644
--- a/components/net/sf/briar/db/SynchronizedDatabaseComponent.java
+++ b/components/net/sf/briar/db/SynchronizedDatabaseComponent.java
@@ -1,9 +1,10 @@
 package net.sf.briar.db;
 
+import java.io.IOException;
+import java.security.SignatureException;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -19,6 +20,8 @@ import net.sf.briar.api.protocol.BatchId;
 import net.sf.briar.api.protocol.Bundle;
 import net.sf.briar.api.protocol.BundleBuilder;
 import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.HeaderBuilder;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageId;
 
@@ -48,8 +51,9 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 
 	@Inject
 	SynchronizedDatabaseComponent(Database<Txn> db, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
-		super(db, cleaner, batchBuilderProvider);
+		super(db, cleaner, headerBuilderProvider, batchBuilderProvider);
 	}
 
 	public void close() throws DbException {
@@ -140,21 +144,20 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 	}
 
 	public Bundle generateBundle(ContactId c, BundleBuilder b)
-	throws DbException {
+	throws DbException, IOException, SignatureException {
 		if(LOG.isLoggable(Level.FINE)) LOG.fine("Generating bundle for " + c);
-		// Ack all batches received from c
+		HeaderBuilder h;
+		// Add acks
 		synchronized(contactLock) {
 			if(!containsContact(c)) throw new NoSuchContactException();
+			h = headerBuilderProvider.get();
 			synchronized(messageStatusLock) {
 				Txn txn = db.startTransaction();
 				try {
-					int numAcks = 0;
-					for(BatchId ack : db.removeBatchesToAck(txn, c)) {
-						b.addAck(ack);
-						numAcks++;
-					}
+					Set<BatchId> acks = db.removeBatchesToAck(txn, c);
+					h.addAcks(acks);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Added " + numAcks + " acks");
+						LOG.fine("Added " + acks.size() + " acks");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -162,19 +165,16 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 				}
 			}
 		}
-		// Add a list of subscriptions
+		// Add subscriptions
 		synchronized(contactLock) {
 			if(!containsContact(c)) throw new NoSuchContactException();
 			synchronized(subscriptionLock) {
 				Txn txn = db.startTransaction();
 				try {
-					int numSubs = 0;
-					for(GroupId g : db.getSubscriptions(txn)) {
-						b.addSubscription(g);
-						numSubs++;
-					}
+					Set<GroupId> subs = db.getSubscriptions(txn);
+					h.addSubscriptions(subs);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Added " + numSubs + " subscriptions");
+						LOG.fine("Added " + subs.size() + " subscriptions");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -188,14 +188,10 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			synchronized(transportLock) {
 				Txn txn = db.startTransaction();
 				try {
-					int numTransports = 0;
 					Map<String, String> transports = db.getTransports(txn);
-					for(Entry<String, String> e : transports.entrySet()) {
-						b.addTransport(e.getKey(), e.getValue());
-						numTransports++;
-					}
+					h.addTransports(transports);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Added " + numTransports + " transports");
+						LOG.fine("Added " + transports.size() + " transports");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -203,8 +199,12 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 				}
 			}
 		}
-		// Add as many messages as possible to the bundle
+		// Sign the header and add it to the bundle
+		Header header = h.build();
 		long capacity = b.getCapacity();
+		capacity -= header.getSize();
+		b.addHeader(header);
+		// Add as many messages as possible to the bundle
 		while(true) {
 			Batch batch = fillBatch(c, capacity);
 			if(batch == null) break; // No more messages to send
@@ -213,7 +213,7 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			capacity -= size;
 			// If the batch is less than half full, stop trying - there may be
 			// more messages trickling in but we can't wait forever
-			if(size * 2 < Batch.CAPACITY) break;
+			if(size * 2 < Batch.MAX_SIZE) break;
 		}
 		Bundle bundle = b.build();
 		if(LOG.isLoggable(Level.FINE))
@@ -222,14 +222,15 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		return bundle;
 	}
 
-	private Batch fillBatch(ContactId c, long capacity) throws DbException {
+	private Batch fillBatch(ContactId c, long capacity) throws DbException,
+	SignatureException {
 		synchronized(contactLock) {
 			if(!containsContact(c)) throw new NoSuchContactException();
 			synchronized(messageLock) {
 				synchronized(messageStatusLock) {
 					Txn txn = db.startTransaction();
 					try {
-						capacity = Math.min(capacity, Batch.CAPACITY);
+						capacity = Math.min(capacity, Batch.MAX_SIZE);
 						Iterator<MessageId> it =
 							db.getSendableMessages(txn, c, capacity).iterator();
 						if(!it.hasNext()) {
@@ -252,6 +253,9 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 					} catch(DbException e) {
 						db.abortTransaction(txn);
 						throw e;
+					} catch(SignatureException e) {
+						db.abortTransaction(txn);
+						throw e;
 					}
 				}
 			}
@@ -331,18 +335,20 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		}
 	}
 
-	public void receiveBundle(ContactId c, Bundle b) throws DbException {
+	public void receiveBundle(ContactId c, Bundle b) throws DbException,
+	IOException, SignatureException {
 		if(LOG.isLoggable(Level.FINE))
 			LOG.fine("Received bundle from " + c + ", "
 					+ b.getSize() + " bytes");
+		Header h;
 		// Mark all messages in acked batches as seen
 		synchronized(contactLock) {
 			if(!containsContact(c)) throw new NoSuchContactException();
+			h = b.getHeader();
 			synchronized(messageLock) {
 				synchronized(messageStatusLock) {
-					int acks = 0;
-					for(BatchId ack : b.getAcks()) {
-						acks++;
+					Set<BatchId> acks = h.getAcks();
+					for(BatchId ack : acks) {
 						Txn txn = db.startTransaction();
 						try {
 							db.removeAckedBatch(txn, c, ack);
@@ -353,7 +359,7 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 						}
 					}
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Received " + acks + " acks");
+						LOG.fine("Received " + acks.size() + " acks");
 				}
 			}
 		}
@@ -363,14 +369,12 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			synchronized(subscriptionLock) {
 				Txn txn = db.startTransaction();
 				try {
+					// FIXME: Replace clearSubs and addSub with setSubs
 					db.clearSubscriptions(txn, c);
-					int subs = 0;
-					for(GroupId g : b.getSubscriptions()) {
-						subs++;
-						db.addSubscription(txn, c, g);
-					}
+					Set<GroupId> subs = h.getSubscriptions();
+					for(GroupId sub : subs) db.addSubscription(txn, c, sub);
 					if(LOG.isLoggable(Level.FINE))
-						LOG.fine("Received " + subs + " subscriptions");
+						LOG.fine("Received " + subs.size() + " subscriptions");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -384,7 +388,11 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 			synchronized(transportLock) {
 				Txn txn = db.startTransaction();
 				try {
-					db.setTransports(txn, c, b.getTransports());
+					Map<String, String> transports = h.getTransports();
+					db.setTransports(txn, c, transports);
+					if(LOG.isLoggable(Level.FINE))
+						LOG.fine("Received " + transports.size()
+								+ " transports");
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -394,7 +402,7 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 		}
 		// Store the messages
 		int batches = 0;
-		for(Batch batch : b.getBatches()) {
+		for(Batch batch = b.getNextBatch(); batch != null; batch = b.getNextBatch()) {
 			batches++;
 			waitForPermissionToWrite();
 			synchronized(contactLock) {
@@ -436,7 +444,7 @@ class SynchronizedDatabaseComponent<Txn> extends DatabaseComponentImpl<Txn> {
 				synchronized(messageStatusLock) {
 					Txn txn = db.startTransaction();
 					try {
-						lost = db.addReceivedBundle(txn, c, b.getId());
+						lost = db.addReceivedBundle(txn, c, h.getId());
 						db.commitTransaction(txn);
 					} catch(DbException e) {
 						db.abortTransaction(txn);
diff --git a/components/net/sf/briar/protocol/BatchImpl.java b/components/net/sf/briar/protocol/BatchImpl.java
index 68d0e12ddc1e7dc000d025be5dbee34e4b38ce91..97ccd5a71c8c0d70ffd3dbbd310cecddbf8cb86f 100644
--- a/components/net/sf/briar/protocol/BatchImpl.java
+++ b/components/net/sf/briar/protocol/BatchImpl.java
@@ -1,8 +1,6 @@
 package net.sf.briar.protocol;
 
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Random;
 
 import net.sf.briar.api.protocol.Batch;
 import net.sf.briar.api.protocol.BatchId;
@@ -11,15 +9,16 @@ import net.sf.briar.api.protocol.Message;
 /** A simple in-memory implementation of a batch. */
 class BatchImpl implements Batch {
 
-	private final List<Message> messages = new ArrayList<Message>();
-	private BatchId id = null;
-	private long size = 0L;
+	private final BatchId id;
+	private final long size;
+	private final List<Message> messages;
+	private final byte[] signature;
 
-	public void seal() {
-		// FIXME: Calculate batch ID
-		byte[] b = new byte[BatchId.LENGTH];
-		new Random().nextBytes(b);
-		id = new BatchId(b);
+	BatchImpl(BatchId id, long size, List<Message> messages, byte[] signature) {
+		this.id = id;
+		this.size = size;
+		this.messages = messages;
+		this.signature = signature;
 	}
 
 	public BatchId getId() {
@@ -34,8 +33,7 @@ class BatchImpl implements Batch {
 		return messages;
 	}
 
-	public void addMessage(Message m) {
-		messages.add(m);
-		size += m.getSize();
+	public byte[] getSignature() {
+		return signature;
 	}
 }
diff --git a/components/net/sf/briar/protocol/BundleReader.java b/components/net/sf/briar/protocol/BundleReader.java
new file mode 100644
index 0000000000000000000000000000000000000000..c3bcdf0ca52d6df14bf7e50a3c0634005f40161e
--- /dev/null
+++ b/components/net/sf/briar/protocol/BundleReader.java
@@ -0,0 +1,94 @@
+package net.sf.briar.protocol;
+
+import java.io.IOException;
+import java.security.SignatureException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Bundle;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.HeaderBuilder;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageParser;
+import net.sf.briar.api.protocol.UniqueId;
+import net.sf.briar.api.serial.FormatException;
+import net.sf.briar.api.serial.Raw;
+import net.sf.briar.api.serial.Reader;
+
+import com.google.inject.Provider;
+
+/** A bundle that deserialises its contents on demand using a reader. */
+abstract class BundleReader implements Bundle {
+
+	private static enum State { START, FIRST_BATCH, MORE_BATCHES, END };
+
+	private final Reader r;
+	private final MessageParser messageParser;
+	private final Provider<HeaderBuilder> headerBuilderProvider;
+	private final Provider<BatchBuilder> batchBuilderProvider;
+	private State state = State.START;
+
+	BundleReader(Reader r, MessageParser messageParser,
+			Provider<HeaderBuilder> headerBuilderProvider,
+			Provider<BatchBuilder> batchBuilderProvider) {
+		this.r = r;
+		this.messageParser = messageParser;
+		this.headerBuilderProvider = headerBuilderProvider;
+		this.batchBuilderProvider = batchBuilderProvider;
+	}
+
+	public Header getHeader() throws IOException, SignatureException {
+		if(state != State.START) throw new IllegalStateException();
+		r.setReadLimit(Header.MAX_SIZE);
+		Set<BatchId> acks = new HashSet<BatchId>();
+		for(Raw raw : r.readList(Raw.class)) {
+			byte[] b = raw.getBytes();
+			if(b.length != UniqueId.LENGTH) throw new FormatException();
+			acks.add(new BatchId(b));
+		}
+		Set<GroupId> subs = new HashSet<GroupId>();
+		for(Raw raw : r.readList(Raw.class)) {
+			byte[] b = raw.getBytes();
+			if(b.length != UniqueId.LENGTH) throw new FormatException();
+			subs.add(new GroupId(b));
+		}
+		Map<String, String> transports = r.readMap(String.class, String.class);
+		byte[] sig = r.readRaw();
+		state = State.FIRST_BATCH;
+		HeaderBuilder h = headerBuilderProvider.get();
+		h.addAcks(acks);
+		h.addSubscriptions(subs);
+		h.addTransports(transports);
+		h.setSignature(sig);
+		return h.build();
+	}
+
+	public Batch getNextBatch() throws IOException, SignatureException {
+		if(state == State.FIRST_BATCH) {
+			r.readListStart();
+			state = State.MORE_BATCHES;
+		}
+		if(state != State.MORE_BATCHES) throw new IllegalStateException();
+		if(r.hasListEnd()) {
+			r.readListEnd();
+			state = State.END;
+			return null;
+		}
+		r.setReadLimit(Batch.MAX_SIZE);
+		List<Raw> messages = r.readList(Raw.class);
+		BatchBuilder b = batchBuilderProvider.get();
+		for(Raw r : messages) {
+			Message m = messageParser.parseMessage(r.getBytes());
+			b.addMessage(m);
+		}
+		byte[] sig = r.readRaw();
+		b.setSignature(sig);
+		return b.build();
+	}
+}
diff --git a/components/net/sf/briar/protocol/BundleWriter.java b/components/net/sf/briar/protocol/BundleWriter.java
new file mode 100644
index 0000000000000000000000000000000000000000..ccd5894752bc4c154de1a83fe336b03f402342d8
--- /dev/null
+++ b/components/net/sf/briar/protocol/BundleWriter.java
@@ -0,0 +1,66 @@
+package net.sf.briar.protocol;
+
+import java.io.IOException;
+
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.BundleBuilder;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.serial.Writer;
+
+/** A bundle builder that serialises its contents using a writer. */
+abstract class BundleWriter implements BundleBuilder {
+
+	private static enum State { START, FIRST_BATCH, MORE_BATCHES, END };
+
+	private final Writer w;
+	private final long capacity;
+	private State state = State.START;
+
+	BundleWriter(Writer w, long capacity) {
+		this.w = w;
+		this.capacity = capacity;
+	}
+
+	public long getCapacity() {
+		return capacity;
+	}
+
+	public void addHeader(Header h) throws IOException {
+		if(state != State.START) throw new IllegalStateException();
+		w.writeListStart();
+		for(BatchId ack : h.getAcks()) w.writeRaw(ack);
+		w.writeListEnd();
+		w.writeListStart();
+		for(GroupId sub : h.getSubscriptions()) w.writeRaw(sub);
+		w.writeListEnd();
+		w.writeMap(h.getTransports());
+		w.writeRaw(h.getSignature());
+		state = State.FIRST_BATCH;
+	}
+
+	public void addBatch(Batch b) throws IOException {
+		if(state == State.FIRST_BATCH) {
+			w.writeListStart();
+			state = State.MORE_BATCHES;
+		}
+		if(state != State.MORE_BATCHES) throw new IllegalStateException();
+		w.writeListStart();
+		for(Message m : b.getMessages()) w.writeRaw(m.getBody());
+		w.writeListEnd();
+		w.writeRaw(b.getSignature());
+	}
+
+	void close() throws IOException {
+		if(state == State.FIRST_BATCH) {
+			w.writeListStart();
+			state = State.MORE_BATCHES;
+		}
+		if(state != State.MORE_BATCHES) throw new IllegalStateException();
+		w.writeListEnd();
+		w.close();
+		state = State.END;
+	}
+}
diff --git a/components/net/sf/briar/protocol/FileBundle.java b/components/net/sf/briar/protocol/FileBundle.java
new file mode 100644
index 0000000000000000000000000000000000000000..930f8699feff1a388e70c9ee62387d4ab36e2eda
--- /dev/null
+++ b/components/net/sf/briar/protocol/FileBundle.java
@@ -0,0 +1,30 @@
+package net.sf.briar.protocol;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.HeaderBuilder;
+import net.sf.briar.api.protocol.MessageParser;
+import net.sf.briar.api.serial.ReaderFactory;
+
+import com.google.inject.Provider;
+
+class FileBundle extends BundleReader {
+
+	private final File file;
+
+	FileBundle(File file, ReaderFactory readerFactory,
+			MessageParser messageParser,
+			Provider<HeaderBuilder> headerBuilderProvider,
+			Provider<BatchBuilder> batchBuilderProvider) throws IOException {
+		super(readerFactory.createReader(new FileInputStream(file)),
+				messageParser, headerBuilderProvider, batchBuilderProvider);
+		this.file = file;
+	}
+
+	public long getSize() throws IOException {
+		return file.length();
+	}
+}
diff --git a/components/net/sf/briar/protocol/FileBundleBuilder.java b/components/net/sf/briar/protocol/FileBundleBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..085f637f74405c52296778929c85ec9d1d80b62a
--- /dev/null
+++ b/components/net/sf/briar/protocol/FileBundleBuilder.java
@@ -0,0 +1,41 @@
+package net.sf.briar.protocol;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.Bundle;
+import net.sf.briar.api.protocol.HeaderBuilder;
+import net.sf.briar.api.protocol.MessageParser;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.WriterFactory;
+
+import com.google.inject.Provider;
+
+public class FileBundleBuilder extends BundleWriter {
+
+	private final File file;
+	private final ReaderFactory readerFactory;
+	private final MessageParser messageParser;
+	private final Provider<HeaderBuilder> headerBuilderProvider;
+	private final Provider<BatchBuilder> batchBuilderProvider;
+
+	FileBundleBuilder(File file, long capacity, WriterFactory writerFactory,
+			ReaderFactory readerFactory, MessageParser messageParser,
+			Provider<HeaderBuilder> headerBuilderProvider,
+			Provider<BatchBuilder> batchBuilderProvider) throws IOException {
+		super(writerFactory.createWriter(new FileOutputStream(file)), capacity);
+		this.file = file;
+		this.readerFactory = readerFactory;
+		this.messageParser = messageParser;
+		this.headerBuilderProvider = headerBuilderProvider;
+		this.batchBuilderProvider = batchBuilderProvider;
+	}
+
+	public Bundle build() throws IOException {
+		super.close();
+		return new FileBundle(file, readerFactory, messageParser,
+				headerBuilderProvider, batchBuilderProvider);
+	}
+}
diff --git a/components/net/sf/briar/protocol/HeaderImpl.java b/components/net/sf/briar/protocol/HeaderImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..763daf0f17e43f24bc4b2a0ec4b88edca2ba4efe
--- /dev/null
+++ b/components/net/sf/briar/protocol/HeaderImpl.java
@@ -0,0 +1,55 @@
+package net.sf.briar.protocol;
+
+import java.util.Map;
+import java.util.Set;
+
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.BundleId;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+
+/** A simple in-memory implementation of a header. */
+class HeaderImpl implements Header {
+
+	private final BundleId id;
+	private final long size;
+	private final Set<BatchId> acks;
+	private final Set<GroupId> subscriptions;
+	private final Map<String, String> transports;
+	private final byte[] signature;
+
+	HeaderImpl(BundleId id, long size, Set<BatchId> acks,
+			Set<GroupId> subscriptions, Map<String, String> transports,
+			byte[] signature) {
+		this.id = id;
+		this.size = size;
+		this.acks = acks;
+		this.subscriptions = subscriptions;
+		this.transports = transports;
+		this.signature = signature;
+	}
+
+	public BundleId getId() {
+		return id;
+	}
+
+	public long getSize() {
+		return size;
+	}
+
+	public Set<BatchId> getAcks() {
+		return acks;
+	}
+
+	public Set<GroupId> getSubscriptions() {
+		return subscriptions;
+	}
+
+	public Map<String, String> getTransports() {
+		return transports;
+	}
+
+	public byte[] getSignature() {
+		return signature;
+	}
+}
diff --git a/components/net/sf/briar/protocol/MessageImpl.java b/components/net/sf/briar/protocol/MessageImpl.java
index 0ae46a8221f77244cf2c3f602aed55f2cb5c3707..64306e4e44db716b1c46be54590257f47f5fa382 100644
--- a/components/net/sf/briar/protocol/MessageImpl.java
+++ b/components/net/sf/briar/protocol/MessageImpl.java
@@ -5,6 +5,7 @@ import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageId;
 
+/** A simple in-memory implementation of a message. */
 public class MessageImpl implements Message {
 
 	private final MessageId id, parent;
@@ -53,7 +54,7 @@ public class MessageImpl implements Message {
 
 	@Override
 	public boolean equals(Object o) {
-		return o instanceof MessageImpl && id.equals(((MessageImpl)o).id);
+		return o instanceof Message && id.equals(((Message)o).getId());
 	}
 
 	@Override
diff --git a/components/net/sf/briar/protocol/ProtocolModule.java b/components/net/sf/briar/protocol/ProtocolModule.java
index 30d41ee8280309124c2c9a283e7a2f81d62971b7..501ad51654e22e03357b11c6f3015cde3a1f34e8 100644
--- a/components/net/sf/briar/protocol/ProtocolModule.java
+++ b/components/net/sf/briar/protocol/ProtocolModule.java
@@ -1,10 +1,8 @@
 package net.sf.briar.protocol;
 
-import net.sf.briar.api.protocol.Batch;
 import net.sf.briar.api.protocol.Message;
 
 import com.google.inject.AbstractModule;
-import com.google.inject.Provides;
 
 public class ProtocolModule extends AbstractModule {
 
@@ -12,9 +10,4 @@ public class ProtocolModule extends AbstractModule {
 	protected void configure() {
 		bind(Message.class).to(MessageImpl.class);
 	}
-
-	@Provides
-	Batch createBatch() {
-		return new BatchImpl();
-	}
 }
diff --git a/test/build.xml b/test/build.xml
index d908f95f32c4a5a40c135546de39f7c7f0f00ba6..f6a2653a303f9519d6b92a6cc978c938c23d4d14 100644
--- a/test/build.xml
+++ b/test/build.xml
@@ -20,6 +20,8 @@
 			<test name='net.sf.briar.i18n.FontManagerTest'/>
 			<test name='net.sf.briar.i18n.I18nTest'/>
 			<test name='net.sf.briar.invitation.InvitationWorkerTest'/>
+			<test name='net.sf.briar.protocol.BundleReaderTest'/>
+			<test name='net.sf.briar.protocol.BundleWriterTest'/>
 			<test name='net.sf.briar.serial.ReaderImplTest'/>
 			<test name='net.sf.briar.serial.WriterImplTest'/>
 			<test name='net.sf.briar.setup.SetupWorkerTest'/>
diff --git a/test/net/sf/briar/db/DatabaseComponentImplTest.java b/test/net/sf/briar/db/DatabaseComponentImplTest.java
index 5532d7a568b32177f350115fe1394a5b872f377a..ba3e7a4e08fc72c9ba66e652f2dde963f0cb51f8 100644
--- a/test/net/sf/briar/db/DatabaseComponentImplTest.java
+++ b/test/net/sf/briar/db/DatabaseComponentImplTest.java
@@ -7,6 +7,7 @@ import java.util.Collections;
 
 import net.sf.briar.api.db.DbException;
 import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.HeaderBuilder;
 import net.sf.briar.api.protocol.MessageId;
 import net.sf.briar.db.DatabaseCleaner.Callback;
 
@@ -24,6 +25,7 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 
 	protected abstract <T> DatabaseComponentImpl<T> createDatabaseComponentImpl(
 			Database<T> database, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider);
 
 	@Test
@@ -33,14 +35,17 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			oneOf(database).getFreeSpace();
 			will(returnValue(MIN_FREE_SPACE));
 		}});
 		Callback db = createDatabaseComponentImpl(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.checkFreeSpaceAndClean();
 
@@ -54,8 +59,11 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			oneOf(database).getFreeSpace();
 			will(returnValue(MIN_FREE_SPACE - 1));
@@ -69,7 +77,7 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 			will(returnValue(MIN_FREE_SPACE));
 		}});
 		Callback db = createDatabaseComponentImpl(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.checkFreeSpaceAndClean();
 
@@ -84,8 +92,11 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			oneOf(database).getFreeSpace();
 			will(returnValue(MIN_FREE_SPACE - 1));
@@ -101,7 +112,7 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 			will(returnValue(MIN_FREE_SPACE));
 		}});
 		Callback db = createDatabaseComponentImpl(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.checkFreeSpaceAndClean();
 
@@ -116,8 +127,11 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			oneOf(database).getFreeSpace();
 			will(returnValue(MIN_FREE_SPACE - 1));
@@ -135,7 +149,7 @@ public abstract class DatabaseComponentImplTest extends DatabaseComponentTest {
 			will(returnValue(MIN_FREE_SPACE));
 		}});
 		Callback db = createDatabaseComponentImpl(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.checkFreeSpaceAndClean();
 
diff --git a/test/net/sf/briar/db/DatabaseComponentTest.java b/test/net/sf/briar/db/DatabaseComponentTest.java
index 0edcdc4be8b8a984d82739fb3b67f9013b697192..174c3a6afd64eb0a2ad2684ba4abbeb9d006b9e1 100644
--- a/test/net/sf/briar/db/DatabaseComponentTest.java
+++ b/test/net/sf/briar/db/DatabaseComponentTest.java
@@ -1,5 +1,7 @@
 package net.sf.briar.db;
 
+import java.io.IOException;
+import java.security.SignatureException;
 import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
@@ -20,6 +22,8 @@ import net.sf.briar.api.protocol.Bundle;
 import net.sf.briar.api.protocol.BundleBuilder;
 import net.sf.briar.api.protocol.BundleId;
 import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.HeaderBuilder;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageId;
 import net.sf.briar.protocol.MessageImpl;
@@ -32,8 +36,6 @@ import com.google.inject.Provider;
 
 public abstract class DatabaseComponentTest extends TestCase {
 
-	private static final int ONE_MEGABYTE = 1024 * 1024;
-
 	protected final Object txn = new Object();
 	protected final AuthorId authorId;
 	protected final BatchId batchId;
@@ -45,6 +47,11 @@ public abstract class DatabaseComponentTest extends TestCase {
 	private final int size;
 	private final byte[] body;
 	private final Message message;
+	private final Set<ContactId> contacts;
+	private final Set<BatchId> acks;
+	private final Set<GroupId> subs;
+	private final Map<String, String> transports;
+	private final Set<MessageId> messages;
 
 	public DatabaseComponentTest() {
 		super();
@@ -60,26 +67,32 @@ public abstract class DatabaseComponentTest extends TestCase {
 		body = new byte[size];
 		message = new MessageImpl(messageId, MessageId.NONE, groupId, authorId,
 				timestamp, body);
+		contacts = Collections.singleton(contactId);
+		acks = Collections.singleton(batchId);
+		subs = Collections.singleton(groupId);
+		transports = Collections.singletonMap("foo", "bar");
+		messages = Collections.singleton(messageId);
 	}
 
 	protected abstract <T> DatabaseComponent createDatabaseComponent(
 			Database<T> database, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider);
 
 	@Test
 	public void testSimpleCalls() throws DbException {
-		final Map<String, String> transports =
-			Collections.singletonMap("foo", "bar");
 		final Map<String, String> transports1 =
 			Collections.singletonMap("foo", "bar baz");
-		final Set<GroupId> subs = Collections.singleton(groupId);
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			allowing(database).startTransaction();
 			will(returnValue(txn));
@@ -95,7 +108,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			will(returnValue(contactId));
 			// getContacts()
 			oneOf(database).getContacts(txn);
-			will(returnValue(Collections.singleton(contactId)));
+			will(returnValue(contacts));
 			// getTransports(contactId)
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
@@ -119,16 +132,16 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).close();
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.open(false);
 		assertEquals(Rating.UNRATED, db.getRating(authorId));
 		assertEquals(contactId, db.addContact(transports));
-		assertEquals(Collections.singleton(contactId), db.getContacts());
+		assertEquals(contacts, db.getContacts());
 		assertEquals(transports, db.getTransports(contactId));
 		db.setTransports(contactId, transports1);
 		db.subscribe(groupId);
-		assertEquals(Collections.singleton(groupId), db.getSubscriptions());
+		assertEquals(subs, db.getSubscriptions());
 		db.unsubscribe(groupId);
 		db.removeContact(contactId);
 		db.close();
@@ -138,14 +151,16 @@ public abstract class DatabaseComponentTest extends TestCase {
 
 	@Test
 	public void testNoParentStopsBackwardInclusion() throws DbException {
-		final Set<MessageId> messages = Collections.singleton(messageId);
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// setRating(Rating.GOOD)
 			allowing(database).startTransaction();
@@ -163,7 +178,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.setRating(authorId, Rating.GOOD);
 
@@ -172,14 +187,16 @@ public abstract class DatabaseComponentTest extends TestCase {
 
 	@Test
 	public void testMissingParentStopsBackwardInclusion() throws DbException {
-		final Set<MessageId> messages = Collections.singleton(messageId);
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// setRating(Rating.GOOD)
 			oneOf(database).startTransaction();
@@ -200,7 +217,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.setRating(authorId, Rating.GOOD);
 
@@ -210,14 +227,16 @@ public abstract class DatabaseComponentTest extends TestCase {
 	@Test
 	public void testChangingGroupsStopsBackwardInclusion() throws DbException {
 		final GroupId groupId1 = new GroupId(TestUtils.getRandomId());
-		final Set<MessageId> messages = Collections.singleton(messageId);
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// setRating(Rating.GOOD)
 			oneOf(database).startTransaction();
@@ -242,7 +261,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.setRating(authorId, Rating.GOOD);
 
@@ -252,14 +271,16 @@ public abstract class DatabaseComponentTest extends TestCase {
 	@Test
 	public void testUnaffectedParentStopsBackwardInclusion()
 	throws DbException {
-		final Set<MessageId> messages = Collections.singleton(messageId);
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// setRating(Rating.GOOD)
 			oneOf(database).startTransaction();
@@ -287,7 +308,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.setRating(authorId, Rating.GOOD);
 
@@ -297,14 +318,16 @@ public abstract class DatabaseComponentTest extends TestCase {
 	@Test
 	public void testAffectedParentContinuesBackwardInclusion()
 	throws DbException {
-		final Set<MessageId> messages = Collections.singleton(messageId);
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// setRating(Rating.GOOD)
 			oneOf(database).startTransaction();
@@ -334,7 +357,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.setRating(authorId, Rating.GOOD);
 
@@ -349,8 +372,11 @@ public abstract class DatabaseComponentTest extends TestCase {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// addLocallyGeneratedMessage(message)
 			oneOf(database).startTransaction();
@@ -360,7 +386,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.addLocallyGeneratedMessage(message);
 
@@ -374,8 +400,11 @@ public abstract class DatabaseComponentTest extends TestCase {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// addLocallyGeneratedMessage(message)
 			oneOf(database).startTransaction();
@@ -387,7 +416,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.addLocallyGeneratedMessage(message);
 
@@ -401,8 +430,11 @@ public abstract class DatabaseComponentTest extends TestCase {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// addLocallyGeneratedMessage(message)
 			oneOf(database).startTransaction();
@@ -412,7 +444,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).addMessage(txn, message);
 			will(returnValue(true));
 			oneOf(database).getContacts(txn);
-			will(returnValue(Collections.singleton(contactId)));
+			will(returnValue(contacts));
 			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
 			// The author is unrated and there are no sendable children
 			oneOf(database).getRating(txn, authorId);
@@ -423,7 +455,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.addLocallyGeneratedMessage(message);
 
@@ -438,8 +470,11 @@ public abstract class DatabaseComponentTest extends TestCase {
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		context.checking(new Expectations() {{
 			// addLocallyGeneratedMessage(message)
 			oneOf(database).startTransaction();
@@ -449,7 +484,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).addMessage(txn, message);
 			will(returnValue(true));
 			oneOf(database).getContacts(txn);
-			will(returnValue(Collections.singleton(contactId)));
+			will(returnValue(contacts));
 			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
 			// The author is rated GOOD and there are two sendable children
 			oneOf(database).getRating(txn, authorId);
@@ -463,7 +498,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.addLocallyGeneratedMessage(message);
 
@@ -472,14 +507,17 @@ public abstract class DatabaseComponentTest extends TestCase {
 
 	@Test
 	public void testGenerateBundleThrowsExceptionIfContactIsMissing()
-	throws DbException {
+	throws DbException, IOException, SignatureException {
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		final BundleBuilder bundleBuilder = context.mock(BundleBuilder.class);
 		context.checking(new Expectations() {{
 			// Check that the contact is still in the DB
@@ -490,7 +528,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		try {
 			db.generateBundle(contactId, bundleBuilder);
@@ -501,15 +539,22 @@ public abstract class DatabaseComponentTest extends TestCase {
 	}
 
 	@Test
-	public void testGenerateBundle() throws DbException {
+	public void testGenerateBundle() throws DbException, IOException,
+	SignatureException {
+		final long headerSize = 1234L;
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		final BundleBuilder bundleBuilder = context.mock(BundleBuilder.class);
+		final HeaderBuilder headerBuilder = context.mock(HeaderBuilder.class);
+		final Header header = context.mock(Header.class);
 		final BatchBuilder batchBuilder = context.mock(BatchBuilder.class);
 		final Batch batch = context.mock(Batch.class);
 		final Bundle bundle = context.mock(Bundle.class);
@@ -519,24 +564,33 @@ public abstract class DatabaseComponentTest extends TestCase {
 			allowing(database).commitTransaction(txn);
 			allowing(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// Add acks to the bundle
+			// Build the header
+			oneOf(headerBuilderProvider).get();
+			will(returnValue(headerBuilder));
+			// Add acks to the header
 			oneOf(database).removeBatchesToAck(txn, contactId);
-			will(returnValue(Collections.singleton(batchId)));
-			oneOf(bundleBuilder).addAck(batchId);
-			// Add subscriptions to the bundle
+			will(returnValue(acks));
+			oneOf(headerBuilder).addAcks(acks);
+			// Add subscriptions to the header
 			oneOf(database).getSubscriptions(txn);
-			will(returnValue(Collections.singleton(groupId)));
-			oneOf(bundleBuilder).addSubscription(groupId);
-			// Add transports to the bundle
+			will(returnValue(subs));
+			oneOf(headerBuilder).addSubscriptions(subs);
+			// Add transports to the header
 			oneOf(database).getTransports(txn);
-			will(returnValue(Collections.singletonMap("foo", "bar")));
-			oneOf(bundleBuilder).addTransport("foo", "bar");
-			// Prepare to add batches to the bundle
+			will(returnValue(transports));
+			oneOf(headerBuilder).addTransports(transports);
+			// Build the header
+			oneOf(headerBuilder).build();
+			will(returnValue(header));
 			oneOf(bundleBuilder).getCapacity();
-			will(returnValue((long) ONE_MEGABYTE));
-			// Add messages to the batch
-			oneOf(database).getSendableMessages(txn, contactId, Batch.CAPACITY);
-			will(returnValue(Collections.singleton(messageId)));
+			will(returnValue(1024L * 1024L));
+			oneOf(header).getSize();
+			will(returnValue(headerSize));
+			oneOf(bundleBuilder).addHeader(header);
+			// Add a batch to the bundle
+			oneOf(database).getSendableMessages(txn, contactId,
+					Batch.MAX_SIZE - headerSize);
+			will(returnValue(messages));
 			oneOf(batchBuilderProvider).get();
 			will(returnValue(batchBuilder));
 			oneOf(database).getMessage(txn, messageId);
@@ -547,8 +601,8 @@ public abstract class DatabaseComponentTest extends TestCase {
 			// Record the batch as outstanding
 			oneOf(batch).getId();
 			will(returnValue(batchId));
-			oneOf(database).addOutstandingBatch(txn, contactId, batchId,
-					Collections.singleton(messageId));
+			oneOf(database).addOutstandingBatch(
+					txn, contactId, batchId, messages);
 			// Add the batch to the bundle
 			oneOf(bundleBuilder).addBatch(batch);
 			// Check whether to add another batch
@@ -559,7 +613,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			will(returnValue(bundle));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.generateBundle(contactId, bundleBuilder);
 
@@ -568,14 +622,17 @@ public abstract class DatabaseComponentTest extends TestCase {
 
 	@Test
 	public void testReceiveBundleThrowsExceptionIfContactIsMissing()
-	throws DbException {
+	throws DbException, IOException, SignatureException {
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		final Bundle bundle = context.mock(Bundle.class);
 		context.checking(new Expectations() {{
 			// Check that the contact is still in the DB
@@ -586,7 +643,7 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		try {
 			db.receiveBundle(contactId, bundle);
@@ -597,17 +654,20 @@ public abstract class DatabaseComponentTest extends TestCase {
 	}
 
 	@Test
-	public void testReceivedBundle() throws DbException {
-		final Map<String, String> transports =
-			Collections.singletonMap("foo", "bar");
+	public void testReceivedBundle() throws DbException, IOException,
+	SignatureException {
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		@SuppressWarnings("unchecked")
-		final Provider<BatchBuilder> batchBuilderProvider =
+		final Provider<HeaderBuilder> headerBuilderProvider =
 			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
 		final Bundle bundle = context.mock(Bundle.class);
+		final Header header = context.mock(Header.class);
 		final Batch batch = context.mock(Batch.class);
 		context.checking(new Expectations() {{
 			allowing(database).startTransaction();
@@ -615,22 +675,25 @@ public abstract class DatabaseComponentTest extends TestCase {
 			allowing(database).commitTransaction(txn);
 			allowing(database).containsContact(txn, contactId);
 			will(returnValue(true));
+			// Header
+			oneOf(bundle).getHeader();
+			will(returnValue(header));
 			// Acks
-			oneOf(bundle).getAcks();
-			will(returnValue(Collections.singleton(batchId)));
+			oneOf(header).getAcks();
+			will(returnValue(acks));
 			oneOf(database).removeAckedBatch(txn, contactId, batchId);
 			// Subscriptions
 			oneOf(database).clearSubscriptions(txn, contactId);
-			oneOf(bundle).getSubscriptions();
-			will(returnValue(Collections.singleton(groupId)));
+			oneOf(header).getSubscriptions();
+			will(returnValue(subs));
 			oneOf(database).addSubscription(txn, contactId, groupId);
 			// Transports
-			oneOf(bundle).getTransports();
+			oneOf(header).getTransports();
 			will(returnValue(transports));
 			oneOf(database).setTransports(txn, contactId, transports);
 			// Batches
-			oneOf(bundle).getBatches();
-			will(returnValue(Collections.singleton(batch)));
+			oneOf(bundle).getNextBatch();
+			will(returnValue(batch));
 			oneOf(batch).getMessages();
 			will(returnValue(Collections.singleton(message)));
 			oneOf(database).containsSubscription(txn, groupId);
@@ -642,15 +705,18 @@ public abstract class DatabaseComponentTest extends TestCase {
 			oneOf(batch).getId();
 			will(returnValue(batchId));
 			oneOf(database).addBatchToAck(txn, contactId, batchId);
+			// Any more batches? Nope
+			oneOf(bundle).getNextBatch();
+			will(returnValue(null));
 			// Lost batches
-			oneOf(bundle).getId();
+			oneOf(header).getId();
 			will(returnValue(bundleId));
 			oneOf(database).addReceivedBundle(txn, contactId, bundleId);
 			will(returnValue(Collections.singleton(batchId)));
 			oneOf(database).removeLostBatch(txn, contactId, batchId);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 
 		db.receiveBundle(contactId, bundle);
 
diff --git a/test/net/sf/briar/db/ReadWriteLockDatabaseComponentTest.java b/test/net/sf/briar/db/ReadWriteLockDatabaseComponentTest.java
index 77e32e60f4c75ff0a65fc5f93a8a7e5dd52bb1ed..ccc1a714d3f4064b2514c3f1b8d659a9243238b6 100644
--- a/test/net/sf/briar/db/ReadWriteLockDatabaseComponentTest.java
+++ b/test/net/sf/briar/db/ReadWriteLockDatabaseComponentTest.java
@@ -2,6 +2,7 @@ package net.sf.briar.db;
 
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.HeaderBuilder;
 
 import com.google.inject.Provider;
 
@@ -11,16 +12,18 @@ extends DatabaseComponentImplTest {
 	@Override
 	protected <T> DatabaseComponent createDatabaseComponent(
 			Database<T> database, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
 		return createDatabaseComponentImpl(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 	}
 
 	@Override
 	protected <T> DatabaseComponentImpl<T> createDatabaseComponentImpl(
 			Database<T> database, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
 		return new ReadWriteLockDatabaseComponent<T>(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 	}
 }
diff --git a/test/net/sf/briar/db/SynchronizedDatabaseComponentTest.java b/test/net/sf/briar/db/SynchronizedDatabaseComponentTest.java
index e562db8ba9c1525f363f6626105d43c93785d1ef..5c8f576c3d849b5aeae43d770cabfe266c2f6954 100644
--- a/test/net/sf/briar/db/SynchronizedDatabaseComponentTest.java
+++ b/test/net/sf/briar/db/SynchronizedDatabaseComponentTest.java
@@ -2,6 +2,7 @@ package net.sf.briar.db;
 
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.HeaderBuilder;
 
 import com.google.inject.Provider;
 
@@ -11,16 +12,18 @@ extends DatabaseComponentImplTest {
 	@Override
 	protected <T> DatabaseComponent createDatabaseComponent(
 			Database<T> database, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
 		return createDatabaseComponentImpl(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 	}
 
 	@Override
 	protected <T> DatabaseComponentImpl<T> createDatabaseComponentImpl(
 			Database<T> database, DatabaseCleaner cleaner,
+			Provider<HeaderBuilder> headerBuilderProvider,
 			Provider<BatchBuilder> batchBuilderProvider) {
 		return new SynchronizedDatabaseComponent<T>(database, cleaner,
-				batchBuilderProvider);
+				headerBuilderProvider, batchBuilderProvider);
 	}
 }
diff --git a/test/net/sf/briar/protocol/BundleReaderTest.java b/test/net/sf/briar/protocol/BundleReaderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b0a5f5d012da9b47b43e82fdfef23e0e7cba56ab
--- /dev/null
+++ b/test/net/sf/briar/protocol/BundleReaderTest.java
@@ -0,0 +1,251 @@
+package net.sf.briar.protocol;
+
+import java.io.IOException;
+import java.security.SignatureException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import junit.framework.TestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchBuilder;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.HeaderBuilder;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageParser;
+import net.sf.briar.api.serial.Raw;
+import net.sf.briar.api.serial.Reader;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.Provider;
+
+public class BundleReaderTest extends TestCase {
+
+	private final long size = 1024L * 1024L;
+	private final BatchId ack = new BatchId(TestUtils.getRandomId());
+	private final List<Raw> rawAcks =
+		Collections.<Raw>singletonList(new TestRaw(ack.getBytes()));
+	private final Set<BatchId> acks = Collections.singleton(ack);
+	private final GroupId sub = new GroupId(TestUtils.getRandomId());
+	private final List<Raw> rawSubs =
+		Collections.<Raw>singletonList(new TestRaw(sub.getBytes()));
+	private final Set<GroupId> subs = Collections.singleton(sub);
+	private final Map<String, String> transports =
+		Collections.singletonMap("foo", "bar");
+	private final byte[] headerSig = TestUtils.getRandomId();
+	private final byte[] messageBody = new byte[123];
+	private final List<Raw> rawMessages =
+		Collections.<Raw>singletonList(new TestRaw(messageBody));
+	private final byte[] batchSig = TestUtils.getRandomId();
+
+	@Test
+	public void testGetHeader() throws IOException, SignatureException {
+		Mockery context = new Mockery();
+		final Reader reader = context.mock(Reader.class);
+		final MessageParser messageParser = context.mock(MessageParser.class);
+		@SuppressWarnings("unchecked")
+		final Provider<HeaderBuilder> headerBuilderProvider =
+			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
+		final HeaderBuilder headerBuilder = context.mock(HeaderBuilder.class);
+		final Header header = context.mock(Header.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).setReadLimit(Header.MAX_SIZE);
+			oneOf(headerBuilderProvider).get();
+			will(returnValue(headerBuilder));
+			// Acks
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawAcks));
+			oneOf(headerBuilder).addAcks(acks);
+			// Subs
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawSubs));
+			oneOf(headerBuilder).addSubscriptions(subs);
+			// Transports
+			oneOf(reader).readMap(String.class, String.class);
+			will(returnValue(transports));
+			oneOf(headerBuilder).addTransports(transports);
+			// Signature
+			oneOf(reader).readRaw();
+			will(returnValue(headerSig));
+			oneOf(headerBuilder).setSignature(headerSig);
+			// Build the header
+			oneOf(headerBuilder).build();
+			will(returnValue(header));
+		}});
+		BundleReader r = createBundleReader(reader, messageParser,
+				headerBuilderProvider, batchBuilderProvider);
+
+		assertEquals(header, r.getHeader());
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testBatchBeforeHeaderThrowsException() throws IOException,
+	SignatureException {
+		Mockery context = new Mockery();
+		final Reader reader = context.mock(Reader.class);
+		final MessageParser messageParser = context.mock(MessageParser.class);
+		@SuppressWarnings("unchecked")
+		final Provider<HeaderBuilder> headerBuilderProvider =
+			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
+		BundleReader r = createBundleReader(reader, messageParser,
+				headerBuilderProvider, batchBuilderProvider);
+
+		try {
+			r.getNextBatch();
+			assertTrue(false);
+		} catch(IllegalStateException expected) {}
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGetHeaderNoBatches() throws IOException,
+	SignatureException {
+		Mockery context = new Mockery();
+		final Reader reader = context.mock(Reader.class);
+		final MessageParser messageParser = context.mock(MessageParser.class);
+		@SuppressWarnings("unchecked")
+		final Provider<HeaderBuilder> headerBuilderProvider =
+			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
+		final HeaderBuilder headerBuilder = context.mock(HeaderBuilder.class);
+		final Header header = context.mock(Header.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).setReadLimit(Header.MAX_SIZE);
+			oneOf(headerBuilderProvider).get();
+			will(returnValue(headerBuilder));
+			// Acks
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawAcks));
+			oneOf(headerBuilder).addAcks(acks);
+			// Subs
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawSubs));
+			oneOf(headerBuilder).addSubscriptions(subs);
+			// Transports
+			oneOf(reader).readMap(String.class, String.class);
+			will(returnValue(transports));
+			oneOf(headerBuilder).addTransports(transports);
+			// Signature
+			oneOf(reader).readRaw();
+			will(returnValue(headerSig));
+			oneOf(headerBuilder).setSignature(headerSig);
+			// Build the header
+			oneOf(headerBuilder).build();
+			will(returnValue(header));
+			// No batches
+			oneOf(reader).readListStart();
+			oneOf(reader).hasListEnd();
+			will(returnValue(true));
+			oneOf(reader).readListEnd();
+		}});
+		BundleReader r = createBundleReader(reader, messageParser,
+				headerBuilderProvider, batchBuilderProvider);
+
+		assertEquals(header, r.getHeader());
+		assertNull(r.getNextBatch());
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGetHeaderOneBatch() throws IOException,
+	SignatureException {
+		Mockery context = new Mockery();
+		final Reader reader = context.mock(Reader.class);
+		final MessageParser messageParser = context.mock(MessageParser.class);
+		@SuppressWarnings("unchecked")
+		final Provider<HeaderBuilder> headerBuilderProvider =
+			context.mock(Provider.class);
+		@SuppressWarnings("unchecked")
+		final Provider<BatchBuilder> batchBuilderProvider =
+			context.mock(Provider.class, "batchBuilderProvider");
+		final HeaderBuilder headerBuilder = context.mock(HeaderBuilder.class);
+		final Header header = context.mock(Header.class);
+		final BatchBuilder batchBuilder = context.mock(BatchBuilder.class);
+		final Batch batch = context.mock(Batch.class);
+		final Message message = context.mock(Message.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).setReadLimit(Header.MAX_SIZE);
+			oneOf(headerBuilderProvider).get();
+			will(returnValue(headerBuilder));
+			// Acks
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawAcks));
+			oneOf(headerBuilder).addAcks(acks);
+			// Subs
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawSubs));
+			oneOf(headerBuilder).addSubscriptions(subs);
+			// Transports
+			oneOf(reader).readMap(String.class, String.class);
+			will(returnValue(transports));
+			oneOf(headerBuilder).addTransports(transports);
+			// Signature
+			oneOf(reader).readRaw();
+			will(returnValue(headerSig));
+			oneOf(headerBuilder).setSignature(headerSig);
+			// Build the header
+			oneOf(headerBuilder).build();
+			will(returnValue(header));
+			// First batch
+			oneOf(reader).readListStart();
+			oneOf(reader).hasListEnd();
+			will(returnValue(false));
+			oneOf(reader).setReadLimit(Batch.MAX_SIZE);
+			oneOf(batchBuilderProvider).get();
+			will(returnValue(batchBuilder));
+			oneOf(reader).readList(Raw.class);
+			will(returnValue(rawMessages));
+			oneOf(messageParser).parseMessage(messageBody);
+			will(returnValue(message));
+			oneOf(batchBuilder).addMessage(message);
+			oneOf(reader).readRaw();
+			will(returnValue(batchSig));
+			oneOf(batchBuilder).setSignature(batchSig);
+			oneOf(batchBuilder).build();
+			will(returnValue(batch));
+			// No more batches
+			oneOf(reader).hasListEnd();
+			will(returnValue(true));
+			oneOf(reader).readListEnd();
+		}});
+		BundleReader r = createBundleReader(reader, messageParser,
+				headerBuilderProvider, batchBuilderProvider);
+
+		assertEquals(header, r.getHeader());
+		assertEquals(batch, r.getNextBatch());
+		assertNull(r.getNextBatch());
+
+		context.assertIsSatisfied();
+	}
+
+	private BundleReader createBundleReader(Reader reader,
+			MessageParser messageParser,
+			Provider<HeaderBuilder> headerBuilderProvider,
+			Provider<BatchBuilder> batchBuilderProvider) {
+		return new BundleReader(reader, messageParser, headerBuilderProvider,
+				batchBuilderProvider) {
+			public long getSize() {
+				return size;
+			}
+		};
+	}
+}
diff --git a/test/net/sf/briar/protocol/BundleWriterTest.java b/test/net/sf/briar/protocol/BundleWriterTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a2e025300321a2af3e11ff4548b84f5431de6d6b
--- /dev/null
+++ b/test/net/sf/briar/protocol/BundleWriterTest.java
@@ -0,0 +1,242 @@
+package net.sf.briar.protocol;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import junit.framework.TestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Bundle;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Header;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.serial.Writer;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+public class BundleWriterTest extends TestCase {
+
+	private final BatchId ack = new BatchId(TestUtils.getRandomId());
+	private final Set<BatchId> acks = Collections.singleton(ack);
+	private final GroupId sub = new GroupId(TestUtils.getRandomId());
+	private final Set<GroupId> subs = Collections.singleton(sub);
+	private final Map<String, String> transports =
+		Collections.singletonMap("foo", "bar");
+	private final byte[] headerSig = TestUtils.getRandomId();
+	private final long capacity = 1024L * 1024L;
+	private final byte[] messageBody = new byte[123];
+	private final byte[] batchSig = TestUtils.getRandomId();
+
+	@Test
+	public void testAddHeader() throws IOException {
+		Mockery context = new Mockery();
+		final Writer writer = context.mock(Writer.class);
+		final Header header = context.mock(Header.class);
+		context.checking(new Expectations() {{
+			// Acks
+			oneOf(writer).writeListStart();
+			oneOf(header).getAcks();
+			will(returnValue(acks));
+			oneOf(writer).writeRaw(ack);
+			oneOf(writer).writeListEnd();
+			// Subs
+			oneOf(writer).writeListStart();
+			oneOf(header).getSubscriptions();
+			will(returnValue(subs));
+			oneOf(writer).writeRaw(sub);
+			oneOf(writer).writeListEnd();
+			// Transports
+			oneOf(header).getTransports();
+			will(returnValue(transports));
+			oneOf(writer).writeMap(transports);
+			// Signature
+			oneOf(header).getSignature();
+			will(returnValue(headerSig));
+			oneOf(writer).writeRaw(headerSig);
+		}});
+		BundleWriter w = createBundleWriter(writer);
+
+		w.addHeader(header);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddHeaderEmptyLists() throws IOException {
+		Mockery context = new Mockery();
+		final Writer writer = context.mock(Writer.class);
+		final Header header = context.mock(Header.class);
+		context.checking(new Expectations() {{
+			// Acks
+			oneOf(writer).writeListStart();
+			oneOf(header).getAcks();
+			will(returnValue(Collections.emptySet()));
+			oneOf(writer).writeListEnd();
+			// Subs
+			oneOf(writer).writeListStart();
+			oneOf(header).getSubscriptions();
+			will(returnValue(Collections.emptySet()));
+			oneOf(writer).writeListEnd();
+			// Transports
+			oneOf(header).getTransports();
+			will(returnValue(Collections.emptyMap()));
+			oneOf(writer).writeMap(Collections.emptyMap());
+			// Signature
+			oneOf(header).getSignature();
+			will(returnValue(headerSig));
+			oneOf(writer).writeRaw(headerSig);
+		}});
+		BundleWriter w = createBundleWriter(writer);
+
+		w.addHeader(header);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testBatchBeforeHeaderThrowsException() throws IOException {
+		Mockery context = new Mockery();
+		final Writer writer = context.mock(Writer.class);
+		final Batch batch = context.mock(Batch.class);
+		BundleWriter w = createBundleWriter(writer);
+
+		try {
+			w.addBatch(batch);
+			assertTrue(false);
+		} catch(IllegalStateException expected) {}
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testCloseBeforeHeaderThrowsException() throws IOException {
+		Mockery context = new Mockery();
+		final Writer writer = context.mock(Writer.class);
+		BundleWriter w = createBundleWriter(writer);
+
+		try {
+			w.close();
+			assertTrue(false);
+		} catch(IllegalStateException expected) {}
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testCloseWithoutBatchesDoesNotThrowException()
+	throws IOException {
+		Mockery context = new Mockery();
+		final Writer writer = context.mock(Writer.class);
+		final Header header = context.mock(Header.class);
+		context.checking(new Expectations() {{
+			// Acks
+			oneOf(writer).writeListStart();
+			oneOf(header).getAcks();
+			will(returnValue(acks));
+			oneOf(writer).writeRaw(ack);
+			oneOf(writer).writeListEnd();
+			// Subs
+			oneOf(writer).writeListStart();
+			oneOf(header).getSubscriptions();
+			will(returnValue(subs));
+			oneOf(writer).writeRaw(sub);
+			oneOf(writer).writeListEnd();
+			// Transports
+			oneOf(header).getTransports();
+			will(returnValue(transports));
+			oneOf(writer).writeMap(transports);
+			// Signature
+			oneOf(header).getSignature();
+			will(returnValue(headerSig));
+			oneOf(writer).writeRaw(headerSig);
+			// Close - write an empty list of batches
+			oneOf(writer).writeListStart();
+			oneOf(writer).writeListEnd();
+			oneOf(writer).close();
+		}});
+		BundleWriter w = createBundleWriter(writer);
+
+		w.addHeader(header);
+		w.close();
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddHeaderAndTwoBatches() throws IOException {
+		Mockery context = new Mockery();
+		final Writer writer = context.mock(Writer.class);
+		final Header header = context.mock(Header.class);
+		final Batch batch = context.mock(Batch.class);
+		final Message message = context.mock(Message.class);
+		context.checking(new Expectations() {{
+			// Acks
+			oneOf(writer).writeListStart();
+			oneOf(header).getAcks();
+			will(returnValue(acks));
+			oneOf(writer).writeRaw(ack);
+			oneOf(writer).writeListEnd();
+			// Subs
+			oneOf(writer).writeListStart();
+			oneOf(header).getSubscriptions();
+			will(returnValue(subs));
+			oneOf(writer).writeRaw(sub);
+			oneOf(writer).writeListEnd();
+			// Transports
+			oneOf(header).getTransports();
+			will(returnValue(transports));
+			oneOf(writer).writeMap(transports);
+			// Signature
+			oneOf(header).getSignature();
+			will(returnValue(headerSig));
+			oneOf(writer).writeRaw(headerSig);
+			// First batch
+			oneOf(writer).writeListStart();
+			oneOf(writer).writeListStart();
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singleton(message)));
+			oneOf(message).getBody();
+			will(returnValue(messageBody));
+			oneOf(writer).writeRaw(messageBody);
+			oneOf(writer).writeListEnd();
+			oneOf(batch).getSignature();
+			will(returnValue(batchSig));
+			oneOf(writer).writeRaw(batchSig);
+			// Second batch
+			oneOf(writer).writeListStart();
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singleton(message)));
+			oneOf(message).getBody();
+			will(returnValue(messageBody));
+			oneOf(writer).writeRaw(messageBody);
+			oneOf(writer).writeListEnd();
+			oneOf(batch).getSignature();
+			will(returnValue(batchSig));
+			oneOf(writer).writeRaw(batchSig);
+			// Close
+			oneOf(writer).writeListEnd();
+			oneOf(writer).close();
+		}});
+		BundleWriter w = createBundleWriter(writer);
+
+		w.addHeader(header);
+		w.addBatch(batch);
+		w.addBatch(batch);
+		w.close();
+
+		context.assertIsSatisfied();
+	}
+
+	private BundleWriter createBundleWriter(Writer writer) {
+		return new BundleWriter(writer, capacity) {
+			public Bundle build() throws IOException {
+				return null;
+			}
+		};
+	}
+}
diff --git a/test/net/sf/briar/protocol/TestRaw.java b/test/net/sf/briar/protocol/TestRaw.java
new file mode 100644
index 0000000000000000000000000000000000000000..f9f685b3632bfaca3244040a4c2c7d05cdcbefa3
--- /dev/null
+++ b/test/net/sf/briar/protocol/TestRaw.java
@@ -0,0 +1,29 @@
+package net.sf.briar.protocol;
+
+import java.util.Arrays;
+
+import net.sf.briar.api.serial.Raw;
+
+class TestRaw implements Raw {
+
+	private final byte[] bytes;
+
+	TestRaw(byte[] bytes) {
+		this.bytes = bytes;
+	}
+
+	public byte[] getBytes() {
+		return bytes;
+	}
+
+	@Override
+	public int hashCode() {
+		return Arrays.hashCode(bytes);
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if(o instanceof Raw) return Arrays.equals(bytes, ((Raw) o).getBytes());
+		return false;
+	}
+}