git » sdk » commit 226c7c6

Bidirectional OMEMO messaging

author Matthew Wild
2025-04-18 18:32:47 UTC
committer Stephen Paul Weber
2025-09-29 13:43:03 UTC
parent c05b84c80d092efc2094b963b832d9ab8fcee1da

Bidirectional OMEMO messaging

Makefile +4 -0
doc/OMEMO.md +16 -0
snikket/Chat.hx +27 -1
snikket/ChatMessage.hx +8 -0
snikket/ChatMessageBuilder.hx +9 -0
snikket/Client.hx +223 -12
snikket/EncryptionInfo.hx +98 -0
snikket/Message.hx +24 -10
snikket/MessageSync.hx +55 -20
snikket/NS.hx +5 -0
snikket/OMEMO.hx +693 -15
snikket/Persistence.hx +5 -2
snikket/persistence/Dummy.hx +9 -2
snikket/persistence/IDB.js +135 -32
snikket/persistence/Sqlite.hx +45 -1

diff --git a/Makefile b/Makefile
index 0b2548d..3b10147 100644
--- a/Makefile
+++ b/Makefile
@@ -33,6 +33,8 @@ npm/snikket-browser.js:
 	sed -i 's/snikket\.ChatMessageEvent/enums.ChatMessageEvent/g' npm/snikket-browser.d.ts
 	sed -i 's/snikket\.ReactionUpdateKind/enums.ReactionUpdateKind/g' npm/snikket-browser.d.ts
 	sed -i 's/snikket\.jingle\.CallStatus/enums.jingle.CallStatus/g' npm/snikket-browser.d.ts
+	sed -i 's/snikket\.EncryptionMode/enums.EncryptionMode/g' npm/snikket-browser.d.ts
+	sed -i 's/snikket\.EncryptionStatus/enums.EncryptionStatus/g' npm/snikket-browser.d.ts
 	sed -i '1ivar exports = {};' npm/snikket-browser.js
 	echo "export const snikket = exports.snikket;" >> npm/snikket-browser.js
 
@@ -47,6 +49,8 @@ npm/snikket.js:
 	sed -i 's/snikket\.ChatMessageEvent/enums.ChatMessageEvent/g' npm/snikket.d.ts
 	sed -i 's/snikket\.ReactionUpdateKind/enums.ReactionUpdateKind/g' npm/snikket.d.ts
 	sed -i 's/snikket\.jingle\.CallStatus/enums.jingle.CallStatus/g' npm/snikket.d.ts
+	sed -i 's/snikket\.EncryptionMode/enums.EncryptionMode/g' npm/snikket.d.ts
+	sed -i 's/snikket\.EncryptionStatus/enums.EncryptionStatus/g' npm/snikket.d.ts
 	sed -i '1iimport { createRequire } from "module";' npm/snikket.js
 	sed -i '1iglobal.require = createRequire(import.meta.url);' npm/snikket.js
 	sed -i '1ivar exports = {};' npm/snikket.js
diff --git a/doc/OMEMO.md b/doc/OMEMO.md
new file mode 100644
index 0000000..ec59791
--- /dev/null
+++ b/doc/OMEMO.md
@@ -0,0 +1,16 @@
+# OMEMO support
+
+Implementation of XEP-0384 v0.3.
+
+Depends on libsignal-protocol-js at runtime.
+
+To disable OMEMO at build time (and thus remove the libsignal dependency)
+compile with the NO_OMEMO flag.
+
+## TODO / known issues
+
+- No caching of remote contact devices
+- No API to control encryption of outgoing messages
+- No API to determine cryptographic identity of message sender
+- Persistence: only IndexedDB backend is currently implemented
+- Encryption status reported by the API can be forged by sender
diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index 1237b4e..c46d21b 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -39,6 +39,16 @@ enum abstract UserState(Int) {
 	var Paused;
 }
 
+// Describes the current encryption mode of the conversation
+// This mode is a high-level representation of the user/app *intent*
+// for the current conversation - e.g. not a guarantee that incoming
+// messages will always match this expectation. It is used to determine
+// the logic for outgoing messages, though.
+enum abstract EncryptionMode(Int) {
+	var Unencrypted; // No end-to-end encryption
+	var EncryptedOMEMO; // Use OMEMO
+}
+
 #if cpp
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
@@ -82,6 +92,8 @@ abstract class Chat {
 	private var activeThread: Null<String> = null;
 	private var notificationSettings: Null<{reply: Bool, mention: Bool}> = null;
 
+	private var _encryptionMode: EncryptionMode = Unencrypted;
+
 	@:allow(snikket)
 	private var omemoContactDeviceIDs: Array<Int> = [];
 
@@ -650,6 +662,17 @@ abstract class Chat {
 		return jingleSessions.flatMap((session) -> session.videoTracks());
 	}
 #end
+	/**
+		Get encryption mode for this chat
+	**/
+	public function encryptionMode(): String {
+		switch(_encryptionMode) {
+			case Unencrypted:
+				return "unencrypted";
+			case EncryptedOMEMO:
+				return "omemo";
+		}
+	}
 
 	@:allow(snikket)
 	private function markReadUpToId(upTo: String, upToBy: String, ?callback: ()->Void) {
@@ -863,7 +886,10 @@ class DirectChat extends Chat {
 							activeThread = message.threadId;
 							stanza.tag("active", { xmlns: "http://jabber.org/protocol/chatstates" }).up();
 						}
-						client.sendStanza(stanza);
+						// FIXME: Preserve ordering with a per-chat outbox of pending messages
+						client.omemo.encryptMessage(recipient, stanza).then((encryptedStanza) -> {
+							client.sendStanza(encryptedStanza);
+						});
 					}
 					setLastMessage(message.build());
 					client.trigger("chats/update", [this]);
diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx
index 38d6d4a..c638bc9 100644
--- a/snikket/ChatMessage.hx
+++ b/snikket/ChatMessage.hx
@@ -178,6 +178,12 @@ class ChatMessage {
 	@:allow(snikket, test)
 	private final payloads: ReadOnlyArray<Stanza>;
 
+	/**
+		Information about the encryption used by the sender of
+		this message.
+	**/
+	public var encryption: Null<EncryptionInfo>;
+
 	@:allow(snikket)
 	private final stanza: Null<Stanza>;
 
@@ -205,6 +211,7 @@ class ChatMessage {
 		?status: MessageStatus,
 		?versions: Array<ChatMessage>,
 		?payloads: Array<Stanza>,
+		?encryption: Null<EncryptionInfo>,
 		?stanza: Null<Stanza>,
 	}) {
 		this.localId = params.localId;
@@ -229,6 +236,7 @@ class ChatMessage {
 		this.status = params.status ?? MessagePending;
 		this.versions = params.versions ?? [];
 		this.payloads = params.payloads ?? [];
+		this.encryption = params.encryption;
 		this.stanza = params.stanza;
 	}
 
diff --git a/snikket/ChatMessageBuilder.hx b/snikket/ChatMessageBuilder.hx
index f9be4b3..5034c24 100644
--- a/snikket/ChatMessageBuilder.hx
+++ b/snikket/ChatMessageBuilder.hx
@@ -123,6 +123,12 @@ class ChatMessageBuilder {
 	@:allow(snikket, test)
 	private var payloads: Array<Stanza> = [];
 
+	/**
+		Information about the encryption used by the sender of
+		this message.
+	**/
+	public var encryption: Null<EncryptionInfo>;
+
 	/**
 		WARNING: if you set this, you promise all the attributes of this builder match it
 	**/
@@ -154,6 +160,7 @@ class ChatMessageBuilder {
 		?status: MessageStatus,
 		?versions: Array<ChatMessage>,
 		?payloads: Array<Stanza>,
+		?encryption: Null<EncryptionInfo>,
 		?html: Null<String>,
 	}) {
 		this.localId = params?.localId;
@@ -174,6 +181,7 @@ class ChatMessageBuilder {
 		this.status = params?.status ?? MessagePending;
 		this.versions = params?.versions ?? [];
 		this.payloads = params?.payloads ?? [];
+		this.encryption = params?.encryption;
 		final html = params?.html;
 		if (html != null) setHtml(html);
 	}
@@ -326,6 +334,7 @@ class ChatMessageBuilder {
 			status: status,
 			versions: versions,
 			payloads: payloads,
+			encryption: encryption,
 			stanza: stanza,
 		});
 	}
diff --git a/snikket/Client.hx b/snikket/Client.hx
index 336e81f..a3acb00 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -334,7 +334,6 @@ class Client extends EventEmitter {
 			}
 
 			trace("pubsubEvent "+Std.string(pubsubEvent!=null));
-			if (pubsubEvent != null && pubsubEvent.getFrom() != null {
 			if (pubsubEvent != null && pubsubEvent.getFrom() != null) {
 				final fromBare = JID.parse(pubsubEvent.getFrom()).asBare();
 				final isOwnAccount = fromBare.asString() == accountId();
@@ -362,19 +361,19 @@ class Client extends EventEmitter {
 				}
 
 				trace("pubsubNode == "+pubsubNode);
-
+			}
 #if !NO_OMEMO
-				if(pubsubNode == "eu.siacs.conversations.axolotl.devicelist") {
-					if(isOwnAccount) {
-						omemo.onAccountUpdatedDeviceList(pubsubEvent.getItems());
-					} else {
-						omemo.onContactUpdatedDeviceList(fromBare, pubsubEvent.getItems());
-					}
-				}
-#end
+			if(stanza.hasChild("encrypted", NS.OMEMO)) {
+				omemo.decryptMessage(stanza).then((decryptedStanza) -> {
+					trace("OMEMO: Decrypted message, now processing...");
+					processLiveMessage(decryptedStanza);
+					return true;
+				});
+				return EventHandled;
 			}
-
-			return EventUnhandled; // Allow others to get this event as well
+#end
+			processLiveMessage(stanza);
+			return EventHandled;
 		});
 
 #if !NO_JINGLE
@@ -589,6 +588,218 @@ class Client extends EventEmitter {
 		});
 	}
 
+	@:allow(snikket)
+	private function processLiveMessage(stanza:Stanza):Void {
+		final from = stanza.attr.get("from") == null ? null : JID.parse(stanza.attr.get("from"));
+
+		if (stanza.attr.get("type") == "error" && from != null) {
+			final chat = getChat(from.asBare().asString());
+			final channel = Std.downcast(chat, Channel);
+			if (channel != null) channel.selfPing(true);
+		}
+
+		var fwd = null;
+		if (from != null && from.asBare().asString() == accountId()) {
+			var carbon = stanza.getChild("received", "urn:xmpp:carbons:2");
+			if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2");
+			if (carbon != null) {
+				fwd = carbon.getChild("forwarded", "urn:xmpp:forward:0")?.getFirstChild();
+			}
+		}
+
+
+		final message = Message.fromStanza(stanza, this.jid, (builder, stanza) -> {
+			var chat = getChat(builder.chatId());
+			if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(builder.chatId());
+			if (chat == null) return builder;
+			return chat.prepareIncomingMessage(builder, stanza);
+		});
+
+		switch (message.parsed) {
+			case ChatMessageStanza(chatMessage):
+				for (hash in chatMessage.inlineHashReferences()) {
+					fetchMediaByHash([hash], [chatMessage.from]);
+				}
+				final chat = getChat(chatMessage.chatId());
+				if (chat != null) {
+					final updateChat = (chatMessage) -> {
+						notifyMessageHandlers(chatMessage, chatMessage.versions.length > 1 ? CorrectionEvent : DeliveryEvent);
+						if (chatMessage.versions.length < 1 || chat.lastMessageId() == chatMessage.serverId || chat.lastMessageId() == chatMessage.localId) {
+							chat.setLastMessage(chatMessage);
+							if (chatMessage.versions.length < 1) chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0);
+							chatActivity(chat);
+						}
+					};
+					if (chatMessage.serverId == null) {
+						updateChat(chatMessage);
+					} else {
+						storeMessages([chatMessage], (stored) -> updateChat(stored[0]));
+					}
+				}
+			case ReactionUpdateStanza(update):
+				for (hash in update.inlineHashReferences()) {
+					fetchMediaByHash([hash], [from]);
+				}
+				persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored, ReactionEvent));
+			default:
+				// ignore
+				trace("Ignoring non-chat message: " + stanza.toString());
+		}
+
+#if !NO_JINGLE
+		final jmiP = stanza.getChild("propose", "urn:xmpp:jingle-message:0");
+		if (jmiP != null && jmiP.attr.get("id") != null) {
+			final session = new IncomingProposedSession(this, from, jmiP.attr.get("id"));
+			final chat = getDirectChat(from.asBare().asString());
+			if (!chat.jingleSessions.exists(session.sid)) {
+				chat.jingleSessions.set(session.sid, session);
+				chatActivity(chat);
+				session.ring();
+			}
+		}
+
+		final jmiR = stanza.getChild("retract", "urn:xmpp:jingle-message:0");
+		if (jmiR != null && jmiR.attr.get("id") != null) {
+			final chat = getDirectChat(from.asBare().asString());
+			final session = chat.jingleSessions.get(jmiR.attr.get("id"));
+			if (session != null) {
+				session.retract();
+				chat.jingleSessions.remove(session.sid);
+			}
+		}
+
+		// Another resource picked this up
+		final jmiProFwd = fwd?.getChild("proceed", "urn:xmpp:jingle-message:0");
+		if (jmiProFwd != null && jmiProFwd.attr.get("id") != null) {
+			final chat = getDirectChat(JID.parse(fwd.attr.get("to")).asBare().asString());
+			final session = chat.jingleSessions.get(jmiProFwd.attr.get("id"));
+			if (session != null) {
+				session.retract();
+				chat.jingleSessions.remove(session.sid);
+			}
+		}
+
+		final jmiPro = stanza.getChild("proceed", "urn:xmpp:jingle-message:0");
+		if (jmiPro != null && jmiPro.attr.get("id") != null) {
+			final chat = getDirectChat(from.asBare().asString());
+			final session = chat.jingleSessions.get(jmiPro.attr.get("id"));
+			if (session != null) {
+				try {
+					chat.jingleSessions.set(session.sid, session.initiate(stanza));
+				} catch (e) {
+					trace("JMI proceed failed", e);
+				}
+			}
+		}
+
+		final jmiRej = stanza.getChild("reject", "urn:xmpp:jingle-message:0");
+		if (jmiRej != null && jmiRej.attr.get("id") != null) {
+			final chat = getDirectChat(from.asBare().asString());
+			final session = chat.jingleSessions.get(jmiRej.attr.get("id"));
+			if (session != null) {
+				session.retract();
+				chat.jingleSessions.remove(session.sid);
+			}
+		}
+#end
+
+		if (stanza.attr.get("type") != "error") {
+			final chatState = stanza.getChild(null, "http://jabber.org/protocol/chatstates");
+			final userState = switch (chatState?.name) {
+				case "active": UserState.Active;
+				case "inactive": UserState.Inactive;
+				case "gone": UserState.Gone;
+				case "composing": UserState.Composing;
+				case "paused": UserState.Paused;
+				default: null;
+			};
+			if (userState != null) {
+				final chat = getChat(from.asBare().asString());
+				if (chat == null || !chat.getParticipantDetails(message.senderId).isSelf) {
+					for (handler in chatStateHandlers) {
+						handler(message.senderId, message.chatId, message.threadId, userState);
+					}
+				}
+			}
+		}
+
+		final pubsubEvent = PubsubEvent.fromStanza(stanza);
+		if (pubsubEvent != null && pubsubEvent.getFrom() != null && pubsubEvent.getNode() == "urn:xmpp:avatar:metadata" && pubsubEvent.getItems().length > 0) {
+			final item = pubsubEvent.getItems()[0];
+			final avatarSha1Hex = pubsubEvent.getItems()[0].attr.get("id");
+			final avatarSha1 = Hash.fromHex("sha-1", avatarSha1Hex)?.hash;
+			final metadata = item.getChild("metadata", "urn:xmpp:avatar:metadata");
+			var mime = "image/png";
+			if (metadata != null) {
+				final info = metadata.getChild("info"); // should have xmlns matching metadata
+				if (info != null && info.attr.get("type") != null) {
+					mime = info.attr.get("type");
+				}
+			}
+			if (avatarSha1 != null) {
+				final chat = this.getDirectChat(JID.parse(pubsubEvent.getFrom()).asBare().asString(), false);
+				chat.setAvatarSha1(avatarSha1);
+				persistence.storeChats(accountId(), [chat]);
+				persistence.hasMedia("sha-1", avatarSha1, (has) -> {
+					if (has) {
+						this.trigger("chats/update", [chat]);
+					} else {
+						final pubsubGet = new PubsubGet(pubsubEvent.getFrom(), "urn:xmpp:avatar:data", avatarSha1Hex);
+						pubsubGet.onFinished(() -> {
+							final item = pubsubGet.getResult()[0];
+							if (item == null) return;
+							final dataNode = item.getChild("data", "urn:xmpp:avatar:data");
+							if (dataNode == null) return;
+							persistence.storeMedia(mime, Base64.decode(StringTools.replace(dataNode.getText(), "\n", "")).getData(), () -> {
+								this.trigger("chats/update", [chat]);
+							});
+						});
+						sendQuery(pubsubGet);
+					}
+				});
+			}
+		}
+
+		trace("pubsubEvent "+Std.string(pubsubEvent!=null));
+		if (pubsubEvent != null && pubsubEvent.getFrom() != null) {
+			final fromBare = JID.parse(pubsubEvent.getFrom()).asBare();
+			final isOwnAccount = fromBare.asString() == accountId();
+			final pubsubNode = pubsubEvent.getNode();
+
+			if (isOwnAccount && pubsubNode == "http://jabber.org/protocol/nick" && pubsubEvent.getItems().length > 0) {
+				updateDisplayName(pubsubEvent.getItems()[0].getChildText("nick", "http://jabber.org/protocol/nick"));
+			}
+
+			if (isOwnAccount && pubsubNode == "urn:xmpp:mds:displayed:0" && pubsubEvent.getItems().length > 0) {
+				for (item in pubsubEvent.getItems()) {
+					if (item.attr.get("id") != null) {
+						final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0");
+						final chat = getChat(item.attr.get("id"));
+						if (chat == null) {
+							startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
+						} else {
+							chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"), () -> {
+								persistence.storeChats(accountId(), [chat]);
+								this.trigger("chats/update", [chat]);
+							});
+						}
+					}
+				}
+			}
+			trace("pubsubNode == "+pubsubNode);
+
+#if !NO_OMEMO
+			if(pubsubNode == "eu.siacs.conversations.axolotl.devicelist") {
+				if(isOwnAccount) {
+					omemo.onAccountUpdatedDeviceList(pubsubEvent.getItems());
+				} else {
+					omemo.onContactUpdatedDeviceList(fromBare, pubsubEvent.getItems());
+				}
+			}
+#end
+		}
+	}
+
 	/**
 		Start this client running and trying to connect to the server
 	**/
diff --git a/snikket/EncryptionInfo.hx b/snikket/EncryptionInfo.hx
new file mode 100644
index 0000000..c5315ee
--- /dev/null
+++ b/snikket/EncryptionInfo.hx
@@ -0,0 +1,98 @@
+package snikket;
+
+enum abstract EncryptionStatus(Int) {
+	var DecryptionSuccess; // Message was encrypted, and we decrypted it
+	var DecryptionFailure; // Message is encrypted, and we failed to decrypt it
+}
+
+@:nullSafety(Strict)
+class EncryptionInfo {
+    public final status:EncryptionStatus;
+    public final method:String;
+    public final methodName:Null<String>;
+    public final reason:Null<String>;
+    public final reasonText:Null<String>;
+
+	// List from XEP-0380
+	private static final knownEncryptionSchemes:Map<String,String> = [
+		"urn:xmpp:otr:0" => "OTR",
+		"jabber:x:encrypted" => "Legacy OpenPGP",
+		"urn:xmpp:openpgp:0" => "OpenPGP",
+		"eu.siacs.conversations.axolotl" => "OMEMO",
+		"urn:xmpp:omemo:1" => "OMEMO 1",
+		"urn:xmpp:omemo:2" => "OMEMO 2",
+    ];
+
+    public function new(status:EncryptionStatus, method:String, ?methodName:String, ?reason:String, ?reasonText:String) {
+        this.status = status;
+        this.method = method;
+        this.methodName = methodName;
+        this.reason = reason;
+        this.reasonText = reasonText;
+    }
+
+    public function toXml():Stanza {
+        final el = new Stanza("decryption-status", {
+            xmlns: "https://snikket.org/protocol/sdk",
+            encryption: this.method,
+            result: status == DecryptionSuccess?"success":"failure",
+        });
+        if(reason != null) {
+            el.textTag("reason", reason);
+        }
+        if(reasonText != null) {
+            el.textTag("text", reasonText);
+        }
+        return el;
+    }
+
+    static public function fromStanza(stanza:Stanza):Null<EncryptionInfo> {
+        final decryptionStatus = stanza.getChild("decryption-status", "https://snikket.org/protocol/sdk");
+		final emeElement = stanza.getChild("encryption", "urn:xmpp:eme:0");
+
+        if(decryptionStatus != null) {
+            if(decryptionStatus.attr.get("result") == "failure") {
+                // We attempted to decrypt this stanza, but something
+                // went wrong. The decryption-status element contains
+                // more information.
+                final ns = decryptionStatus.attr.get("encryption");
+                return new EncryptionInfo(
+                    DecryptionFailure,
+                    ns??"unknown",
+                    ns != null?knownEncryptionSchemes.get(ns):"Unknown encryption",
+                    decryptionStatus.getChildText("reason"), // Machine-readable reason (depends on encryption method)
+                    decryptionStatus.getChildText("text"), // Human-readable explanation
+                );
+            } else {
+                final encryptionMethod = decryptionStatus.attr.get("encryption")??"unknown";
+                return new EncryptionInfo(
+                    DecryptionSuccess,
+                    encryptionMethod,
+                    knownEncryptionSchemes.get(encryptionMethod)??"Unknown encryption"
+                );
+            }
+        } else {
+			// We did not decrypt this stanza, so check for any signs
+			// that it was encrypted in the first place...
+			var ns = null, name = null;
+			if(emeElement != null) {
+				ns = emeElement.attr.get("namespace");
+				name = emeElement.attr.get("name");
+			} else if(stanza.getChild("encrypted", "eu.siacs.conversations.axolotl") != null) {
+				// Special handling for OMEMO without EME, just because it is
+				// so widely used.
+				ns = "eu.siacs.conversations.axolotl";
+			}
+            if(ns != null) {
+                return new EncryptionInfo(
+                    DecryptionFailure,
+                    ns??"unknown",
+                    knownEncryptionSchemes.get(ns)??name??"Unknown encryption",
+                    "unsupported-encryption",
+                    "Unsupported encryption method"
+                );
+            }
+		}
+        return null; // Probably not encrypted
+    }
+}
diff --git a/snikket/Message.hx b/snikket/Message.hx
index 39916ca..c307446 100644
--- a/snikket/Message.hx
+++ b/snikket/Message.hx
@@ -29,6 +29,7 @@ enum MessageStanza {
 	ModerateMessageStanza(action: ModerationAction);
 	ReactionUpdateStanza(update: ReactionUpdate);
 	UnknownMessageStanza(stanza: Stanza);
+	UndecryptableMessageStanza(decryptionFailure: EncryptionInfo);
 }
 
 @:nullSafety(Strict)
@@ -36,19 +37,30 @@ class Message {
 	public final chatId: String;
 	public final senderId: String;
 	public final threadId: Null<String>;
+	public final encryption: Null<EncryptionInfo>;
 	public final parsed: MessageStanza;
 
-	private function new(chatId: String, senderId: String, threadId: Null<String>, parsed: MessageStanza) {
+	private function new(chatId: String, senderId: String, threadId: Null<String>, parsed: MessageStanza, encryption:Null<EncryptionInfo>) {
 		this.chatId = chatId;
 		this.senderId = senderId;
 		this.threadId = threadId;
 		this.parsed = parsed;
+		this.encryption = encryption;
 	}
 
 	public static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder):Message {
 		final fromAttr = stanza.attr.get("from");
 		final from = fromAttr == null ? localJid.domain : fromAttr;
-		if (stanza.attr.get("type") == "error") return new Message(from, from, null, ErrorMessageStanza(stanza));
+		final encryptionInfo = EncryptionInfo.fromStanza(stanza);
+
+		if (stanza.attr.get("type") == "error") {
+			return new Message(from, from, null, ErrorMessageStanza(stanza), encryptionInfo);
+		}
+
+		if(encryptionInfo != null && encryptionInfo.status == DecryptionFailure) {
+			trace("Message decryption failure: " + encryptionInfo.reasonText);
+			return new Message(from, from, stanza.getChildText("thread"), UndecryptableMessageStanza(encryptionInfo), encryptionInfo);
+		}
 
 		var msg = new ChatMessageBuilder();
 		msg.stanza = stanza;
@@ -70,6 +82,7 @@ class Message {
 		final domain = localJid.domain;
 		final to = stanza.attr.get("to");
 		msg.to = to == null ? localJid : JID.parse(to);
+		msg.encryption = encryptionInfo;
 
 		if (msg.from != null && msg.from.equals(localJidBare)) {
 			var carbon = stanza.getChild("received", "urn:xmpp:carbons:2");
@@ -129,7 +142,7 @@ class Message {
 					replyTo.clear();
 				} else if (jid == null) {
 					trace("No support for addressing to non-jid", address);
-					return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza));
+					return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza), encryptionInfo);
 				} else if (address.attr.get("type") == "to" || address.attr.get("type") == "cc") {
 					recipients[JID.parse(jid).asBare().asString()] = true;
 					if (!anyExtendedReplyTo) replyTo[JID.parse(jid).asString()] = true; // reply all
@@ -157,7 +170,7 @@ class Message {
 		// Not sure why the compiler things we need to use Null<JID> with findFast
 		if (msg.direction == MessageReceived && msgFrom != null && Util.findFast(msg.replyTo, @:nullSafety(Off) (r: Null<JID>) -> r.asBare().equals(msgFrom.asBare())) == null) {
 			trace("Don't know what chat message without from in replyTo belongs in", stanza);
-			return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza));
+			return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza), encryptionInfo);
 		}
 
 		if (addContext != null) msg = addContext(msg, stanza);
@@ -180,7 +193,7 @@ class Message {
 					timestamp,
 					reactions.map(text -> new Reaction(msg.senderId, timestamp, text, msg.localId)),
 					EmojiReactions
-				)));
+				)), encryptionInfo);
 			}
 		}
 
@@ -219,14 +232,15 @@ class Message {
 				msg.chatId(),
 				msg.senderId,
 				msg.threadId,
-				ModerateMessageStanza(new ModerationAction(msg.chatId(), moderateServerId, timestamp, by, reason))
+				ModerateMessageStanza(new ModerationAction(msg.chatId(), moderateServerId, timestamp, by, reason)),
+				encryptionInfo
 			);
 		}
 
 		final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0");
 		final replaceId  = replace?.attr?.get("id");
 
-		if (msg.text == null && msg.attachments.length < 1 && replaceId == null) return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza));
+		if (msg.text == null && msg.attachments.length < 1 && replaceId == null) return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza), encryptionInfo);
 
 		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
 			msg.payloads.push(fallback);
@@ -259,7 +273,7 @@ class Message {
 					timestamp,
 					[new Reaction(msg.senderId, timestamp, text.trim(), msg.localId)],
 					AppendReactions
-				)));
+				)), encryptionInfo);
 			}
 
 			if (html != null) {
@@ -279,7 +293,7 @@ class Message {
 								timestamp,
 								[new CustomEmojiReaction(msg.senderId, timestamp, els[0].attr.get("alt") ?? "", hash.serializeUri(), msg.localId)],
 								AppendReactions
-							)));
+							)), encryptionInfo);
 						}
 					}
 				}
@@ -306,6 +320,6 @@ class Message {
 			msg.localId = replaceId;
 		}
 
-		return new Message(msg.chatId(), msg.senderId, msg.threadId, ChatMessageStanza(msg.build()));
+		return new Message(msg.chatId(), msg.senderId, msg.threadId, ChatMessageStanza(msg.build()), encryptionInfo);
 	}
 }
diff --git a/snikket/MessageSync.hx b/snikket/MessageSync.hx
index 0c9bb79..2fe08ec 100644
--- a/snikket/MessageSync.hx
+++ b/snikket/MessageSync.hx
@@ -1,20 +1,25 @@
 package snikket;
 
 import haxe.Exception;
-
 import snikket.Client;
 import snikket.Message;
 import snikket.GenericStream;
 import snikket.ResultSet;
 import snikket.queries.MAMQuery;
 
+import thenshim.Promise;
+import thenshim.PromiseTools;
+
+#if !NO_OMEMO
+import snikket.OMEMO;
+#end
+
 typedef MessageList = {
-	var sync : MessageSync;
-	var messages : Array<MessageStanza>;
+	var sync:MessageSync;
+	var messages:Array<MessageStanza>;
 }
 
-typedef MessageListHandler = (MessageList)->Void;
-
+typedef MessageListHandler = (MessageList) -> Void;
 typedef MessageFilter = MAMQueryParams;
 
 class MessageSync {
@@ -46,14 +51,16 @@ class MessageSync {
 		if (complete) {
 			throw new Exception("Attempt to fetch messages, but already complete");
 		}
-		final messages:Array<MessageStanza> = [];
+		final promisedMessages:Array<Promise<MessageStanza>> = [];
 		if (lastPage == null) {
 			if (newestPageFirst == true && (filter.page == null || (filter.page.before == null && filter.page.after == null))) {
-				if (filter.page == null) filter.page = {};
+				if (filter.page == null)
+					filter.page = {};
 				filter.page.before = ""; // Request last page of results
 			}
 		} else {
-			if (filter.page == null) filter.page = {};
+			if (filter.page == null)
+				filter.page = {};
 			if (newestPageFirst == true) {
 				filter.page.before = lastPage.first;
 			} else {
@@ -83,32 +90,60 @@ class MessageSync {
 				jmi.set(jmiChildren[0].attr.get("id"), originalMessage);
 			}
 
-			final msg = Message.fromStanza(originalMessage, client.jid, (builder, stanza) -> {
-				builder.serverId = result.attr.get("id");
-				builder.serverIdBy = serviceJID;
-				if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
-				return contextHandler(builder, stanza);
-			}).parsed;
+			if (originalMessage.hasChild("encrypted", NS.OMEMO)) {
+#if !NO_OMEMO
+				trace("MAM: Processing OMEMO message from " + originalMessage.attr.get("from"));
+				promisedMessages.push(client.omemo.decryptMessage(originalMessage).then((decryptedStanza) -> {
+					trace("MAM: Decrypted stanza: "+decryptedStanza);
+
+					final msg = Message.fromStanza(decryptedStanza, client.jid, (builder, stanza) -> {
+						builder.serverId = result.attr.get("id");
+						builder.serverIdBy = serviceJID;
+						if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
+						return contextHandler(builder, stanza);
+					}).parsed;
+
+					return msg;
+				}, (err) -> {
+					trace("MAM: Decryption failed: "+err);
+					return null;
+				}));
+#end
+				return EventHandled;
+			} else {
+				trace("MAM: Processing non-OMEMO message from " + originalMessage.attr.get("from"));
 
-			messages.push(msg);
+				final msg = Message.fromStanza(originalMessage, client.jid, (builder, stanza) -> {
+					builder.serverId = result.attr.get("id");
+					builder.serverIdBy = serviceJID;
+					if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
+					return contextHandler(builder, stanza);
+				}).parsed;
+
+				promisedMessages.push(Promise.resolve(msg));
+				//messages.push(msg);
+			}
 
 			return EventHandled;
 		});
-		query.onFinished(function () {
+		query.onFinished(function() {
 			resultHandler.unsubscribe();
 			var result = query.getResult();
 			if (result == null) {
 				trace("Error from MAM, stopping sync");
 				complete = true;
-				if (errorHandler != null) errorHandler(query.responseStanza);
+				if (errorHandler != null)
+					errorHandler(query.responseStanza);
 			} else {
 				complete = result.complete;
 				lastPage = result.page;
 			}
 			if (result != null || errorHandler == null) {
-				handler({
-					sync: this,
-					messages: messages,
+				PromiseTools.all(promisedMessages).then((messages) -> {
+					handler({
+						sync: this,
+						messages: messages,
+					});
 				});
 			}
 		});
diff --git a/snikket/NS.hx b/snikket/NS.hx
new file mode 100644
index 0000000..1dbb977
--- /dev/null
+++ b/snikket/NS.hx
@@ -0,0 +1,5 @@
+package snikket;
+
+class NS {
+    static public final OMEMO = "eu.siacs.conversations.axolotl";
+}
diff --git a/snikket/OMEMO.hx b/snikket/OMEMO.hx
index 0c52123..613edf8 100644
--- a/snikket/OMEMO.hx
+++ b/snikket/OMEMO.hx
@@ -1,21 +1,39 @@
 package snikket;
 
+import haxe.io.BytesBuffer;
+import snikket.EncryptedMessage;
+import snikket.Message;
+
 import snikket.queries.PubsubGet;
 import snikket.queries.PubsubPublish;
 
 import haxe.crypto.Base64;
 import haxe.io.Bytes;
+import haxe.io.BytesData;
 
 import thenshim.Promise;
 import thenshim.PromiseTools;
 
 using snikket.SignalProtocol;
 
+#if js
+import js.Browser;
+#end
+
 @:structInit
 class OMEMOBundleSignedPreKey {
 	public final id: Int;
 	public final public_key: String;
 	public final signature: String;
+
+	static public function fromSignedPreKeyPair(signedPreKey:SignedPreKey):OMEMOBundleSignedPreKey {
+		final bundlePreKey:OMEMOBundleSignedPreKey = {
+			id: signedPreKey.keyId,
+			public_key: Base64.encode(Bytes.ofData(signedPreKey.keyPair.pubKey)),
+			signature: Base64.encode(Bytes.ofData(signedPreKey.signature)),
+		};
+		return bundlePreKey;
+	}
 }
 
 @:structInit
@@ -45,11 +63,246 @@ class OMEMOBundle {
 		bundleTag.up();
 		return bundleTag;
 	}
+
+	static public function fromXml(stanza:Stanza, deviceId:Int):OMEMOBundle {
+		return {
+			identity_key: stanza.getChildText("identityKey"),
+			device_id: deviceId,
+			signed_prekey: {
+				id: Std.parseInt(stanza.findText("signedPreKeyPublic@signedPreKeyId")),
+				public_key: stanza.getChildText("signedPreKeyPublic"),
+				signature: stanza.getChildText("signedPreKeySignature"),
+			},
+			prekeys: [
+				for(keyTag in stanza.getChild("prekeys").allTags("preKeyPublic")) {
+					{
+						keyId: Std.parseInt(keyTag.attr.get("preKeyId")),
+						pubKey: keyTag.getText(),
+					}
+				}
+			],
+		}
+	}
+
+	public function getRandomPreKey():PublicPreKey {
+		return prekeys[Std.random(prekeys.length-1)];
+	}
 }
 
+class OMEMOStore extends SignalProtocolStore {
+	private final accountId: String;
+	private final persistence: Persistence;
+
+	public function new(accountId:String, persistence:Persistence) {
+		this.accountId = accountId;
+		this.persistence = persistence;
+	}
+
+	// Load the identity keypair for our account
+	public function getIdentityKeyPair():Promise<IdentityKeyPair> {
+		return new Promise((resolve, reject)->persistence.getOmemoIdentityKey(accountId, resolve));
+	}
+
+	public function getLocalRegistrationId():Promise<Int> {
+		return new Promise((resolve, reject)->persistence.getOmemoId(accountId, resolve));
+	}
+
+	public function isTrustedIdentity(identifier:String, identityKey:IdentityPublicKey, _direction:Int):Promise<Bool> {
+		return Promise.resolve(true); // FIXME?
+	}
+
+	// Load the identity key of a contact (partners with saveIdentity())
+	public function loadIdentityKey(identifier:SignalProtocolAddress):Promise<IdentityPublicKey> {
+		return new Promise((resolve, reject)->persistence.getOmemoContactIdentityKey(accountId, identifier.toString(), resolve));
+	}
+
+	public function saveIdentity(identifier:SignalProtocolAddress, identityKey:IdentityPublicKey):Promise<Bool> {
+		return new Promise((resolve, reject)-> {
+			persistence.getOmemoContactIdentityKey(accountId, identifier.toString(), (prevKey)->{
+				persistence.storeOmemoContactIdentityKey(accountId, identifier.toString(), identityKey);
+				// Return true if the key was updated, false if it matches what we already had stored
+				resolve(prevKey != identityKey);
+			});
+		});
+	}
+
+	public function loadPreKey(keyId:Int):Promise<PreKeyPair> {
+		return new Promise((resolve, reject) -> {
+			persistence.getOmemoPreKey(accountId, keyId, resolve);
+		});
+	}
+
+	
+
+	public function storePreKey(keyId:Int, keyPair:PreKeyPair):Promise<Bool> {
+		return new Promise((resolve, reject) -> {
+			persistence.storeOmemoPreKey(accountId, keyId, keyPair);
+			resolve(true);
+		});
+	}
+
+	public function removePreKey(keyId:Int):Promise<Bool> {
+		persistence.removeOmemoPreKey(accountId, keyId);
+		// FIXME: Need to signal that we need to generate a replacement
+		// for the consumed prekey and republish our bundle
+		return Promise.resolve(true);
+	}
+
+	public function loadSignedPreKey(keyId:Int):Promise<PreKeyPair> {
+		trace("OMEMO: FIXME A: Loading signed prekey "+keyId);
+		return new Promise((resolve, reject) -> {
+			persistence.getOmemoSignedPreKey(accountId, keyId, (signedPreKey) -> {
+				resolve(signedPreKey.keyPair);
+			});
+		});
+	}
+
+	public function storeSignedPreKey(keyId:Int, keyPair:SignedPreKey):Promise<Bool> {
+		trace("OMEMO: FIXME B: Storing signed prekey "+keyId);
+		return new Promise((resolve, reject) -> {
+			persistence.storeOmemoSignedPreKey(accountId, keyPair);
+			resolve(true);
+		});
+	}
+
+	public function removeSignedPreKey(keyId:Int):Promise<Bool> {
+		throw new haxe.exceptions.NotImplementedException();
+	}
+
+	public function loadSession(identifier:SignalProtocolAddress):Promise<SignalSession> {
+		return new Promise<SignalSession>((resolve, reject) -> {
+			persistence.getOmemoSession(accountId, identifier.toString(), resolve);
+		});
+	}
+
+	public function storeSession(identifier:SignalProtocolAddress, session:SignalSession):Promise<Bool> {
+		persistence.storeOmemoSession(accountId, identifier.toString(), session);
+		return Promise.resolve(true);
+	}
+
+	public function removeSession(identifier:SignalProtocolAddress):Promise<Bool> {
+		throw new haxe.exceptions.NotImplementedException();
+	}
+
+	public function removeAllSessions(identifier:SignalProtocolAddress):Promise<Bool> {
+		throw new haxe.exceptions.NotImplementedException();
+	}
+}
+
+@:structInit
+class OMEMOPayloadKey {
+	public final rid:Int;
+	public final prekey:Bool;
+	public final encodedKey:String;
+
+	public function getRawKey():BytesData {
+		return Base64.decode(encodedKey).getData();
+	}
+}
+
+// Represents an OMEMO payload in a message
+@:structInit
+class OMEMOPayload {
+	public final sid:Int;
+	public final keys:Array<OMEMOPayloadKey>;
+	public final encodedIv:String;
+	public final encodedPayload:String;
+
+	public function toXml():Stanza {
+		final el = new Stanza("encrypted", { xmlns: "eu.siacs.conversations.axolotl" });
+		el.tag("header", { sid: Std.string(sid) });
+		for (key in keys) {
+			if(key.prekey) {
+				el.textTag("key", key.encodedKey, { rid: Std.string(key.rid), prekey: "true" });
+			} else {
+				el.textTag("key", key.encodedKey, { rid: Std.string(key.rid) });
+			}
+		}
+		el.textTag("iv", encodedIv);
+		el.up();
+		el.textTag("payload", encodedPayload);
+		return el;
+	}
+
+	public static function fromXml(tag:Stanza):Null<OMEMOPayload> {
+		final header = tag.getChild("header");
+		final sid = header.attr.get("sid");
+		final encodedIv = header.getChildText("iv");
+		final encodedPayload = tag.getChildText("payload");
+		final keys:Array<OMEMOPayloadKey> = [
+			for(key in header.allTags("key")) {
+				{
+					rid: Std.parseInt(key.attr.get("rid")),
+					prekey: Stanza.parseXmlBool(key.attr.get("prekey")),
+					encodedKey: key.getText(),
+				}
+			}
+		];
+		return {
+			sid: Std.parseInt(sid),
+			keys: keys,
+			encodedIv: encodedIv,
+			encodedPayload: encodedPayload,
+		};
+	}
+
+	public static function fromMessageStanza(message:Stanza):Null<OMEMOPayload> {
+		final encrypted = message.getChild("encrypted", "eu.siacs.conversations.axolotl");
+		if(encrypted == null) {
+			return null;
+		}
+		return fromXml(encrypted);
+	}
+	
+	public function getRawIv():BytesData {
+		return Base64.decode(encodedIv).getData();
+	}
+
+	public function getRawPayload():BytesData {
+		return Base64.decode(encodedPayload).getData();
+	}
+
+	public function findKey(deviceId:Int):Null<OMEMOPayloadKey> {
+		for(key in keys) {
+			if(key.rid == deviceId) {
+				return key;
+			}
+		}
+		trace("OMEMO: Key missing in OMEMO header of "+keys.length+" keys. Looked for "+deviceId+" in "+([for (key in keys) key.rid].join(", ")));
+		return null; // Key not found
+	}
+}
+
+// The result of the OMEMO encryption step
+// Combine with recipient sessions to produce an OMEMOPayload
+class OMEMOEncryptionResult {
+	public var iv:BytesData;
+	public var key:BytesData;
+	public var ciphertext:BytesData;
+	public var tag:BytesData;
+
+	public function new() {}
+
+	private var keyWithTag:BytesData = null;
+	public function getKeyWithTag():BytesData {
+		if(keyWithTag != null) {
+			return keyWithTag;
+		}
+		final keyBytes = Bytes.ofData(key);
+		final tagBytes = Bytes.ofData(tag);
+		final buffer = Bytes.alloc(keyBytes.length + tagBytes.length);
+		buffer.blit(0, keyBytes, 0, keyBytes.length);
+		buffer.blit(keyBytes.length, tagBytes, 0, tagBytes.length);
+		keyWithTag = buffer.getData();
+		return keyWithTag;
+	}
+}
+
+//@:nullSafety(Strict)
 class OMEMO {
 	private final client: Client;
 	private final persistence: Persistence;
+	private final signalStore: OMEMOStore;
 
 	// Track the status of our bundle state locally
 	private final bundleLocalState:FSM;
@@ -62,6 +315,17 @@ class OMEMO {
 	// An array of all our device IDs on our account
 	public var deviceList:Array<Int>;
 
+	#if js
+	// Constant used by JS's subtle encrypt/decrypt routines
+	private final keyAlgorithm = {
+		name: "AES-GCM",
+		length: 128,
+	};
+	private final keyPurposeDecrypt = ["decrypt"];
+	private final keyPurposeEncrypt = ["encrypt"];
+	private final keyPurposeBoth = ["encrypt", "decrypt"];
+	#end
+
 	// Recommended number of prekeys, per the XEP
 	private final NUM_PREKEYS = 100;
 	private static final publicNodeConfig:PubsubConfig = {
@@ -75,6 +339,7 @@ class OMEMO {
 	public function new(client_: Client, persistence_: Persistence) {
 		client = client_;
 		persistence = persistence_;
+		signalStore = new OMEMOStore(client.accountId(), persistence);
 
 		 bundleLocalState = new FSM({
 			transitions: [
@@ -85,6 +350,7 @@ class OMEMO {
 			state_handlers: [
 				"loading" => loadBundle,
 				"creating" => createLocalBundle,
+				"ok" => onLocalBundleReady,
 			],
 			transition_handlers: [
 			],
@@ -108,11 +374,22 @@ class OMEMO {
 		}, "unverified");
 
 		client.on("session-started", function (event) {
-			bundlePublicState.event("verify");
+			// If we're not already busy, verify our published
+			// bundle after starting a new session (since we 
+			// may have missed notifications about it changing)
+			if(bundleLocalState.getCurrentState() == "ok" && bundlePublicState.can("verify")) {
+				bundlePublicState.event("verify");
+			}
 			return EventHandled;
 		});
 	}
 
+	private function onLocalBundleReady(event) {
+		if(bundlePublicState.getCurrentState() == "unverified") {
+			bundlePublicState.event("verify");
+		}
+	}
+
 	private function loadBundle(event) {
 		var bundleSignedPreKey:OMEMOBundleSignedPreKey;
 		var newBundle = {
@@ -149,14 +426,14 @@ class OMEMO {
 		});
 
 		final pSignedPreKey = new Promise(function (resolve, reject) {
-			persistence.getOmemoSignedPreKey(client.accountId(), 1, resolve);
+			persistence.getOmemoSignedPreKey(client.accountId(), 0, resolve);
 		}).then(function (signedPreKey) {
 			if(signedPreKey == null) {
 				trace("No signed prekey stored");
 				return false;
 			}
 			trace("Loaded signed prekey");
-			newBundle.signed_prekey = signedPreKey;
+			newBundle.signed_prekey = OMEMOBundleSignedPreKey.fromSignedPreKeyPair(signedPreKey);
 			return true;
 		});
 
@@ -210,7 +487,7 @@ class OMEMO {
 
 	// Wait for our local bundle to be ready for publication
 	private function waitForBundleReady(event) {
-		if(bundleLocalState.getState() == "ok") {
+		if(bundleLocalState.getCurrentState() == "ok") {
 			// No need to wait!
 			bundlePublicState.event("needs-update");
 			return;
@@ -237,7 +514,7 @@ class OMEMO {
 	}
 
 	private function updatePublishedBundle(event) {
-		if(bundleLocalState.getState() != "ok") {
+		if(bundleLocalState.getCurrentState() != "ok") {
 			trace("Can't publish yet - waiting for local bundle");
 			bundlePublicState.event("wait");
 			return;
@@ -265,6 +542,20 @@ class OMEMO {
 		return devices;
 	}
 
+	private function bundleFromPubsubItems(items:Array<Stanza>, deviceId:Int):Null<OMEMOBundle> {
+		if(items.length == 0) {
+			trace("No items in bundle");
+			return null;
+		}
+		var item = items[0].getChild("bundle", "eu.siacs.conversations.axolotl");
+		if (item == null) {
+			trace("First item did not contain valid bundle");
+			return null;
+		}
+		// FIXME - extract stuff
+		return OMEMOBundle.fromXml(item, deviceId);
+	}
+
 	// Called when we receive an updated device list for our own account
 	public function onAccountUpdatedDeviceList(items:Array<Stanza>) {
 		trace("OMEMO: onAccountUpdatedDeviceList");
@@ -289,7 +580,7 @@ class OMEMO {
 		var devices = deviceIdsFromPubsubItems(items);
 		if(devices != null) {
 			chat.omemoContactDeviceIDs = devices;
-			persistence.storeChat(client.accountId(), chat);
+			persistence.storeChats(client.accountId(), [chat]);
 		}
 	}
 
@@ -363,19 +654,15 @@ class OMEMO {
 			
 			return KeyHelper.generateSignedPreKey(identityKeyPair, 0);
 		}).then(cast function (signedPreKey:SignedPreKey):Bool {
-			// store.js:283
-			final stored_signed_prekey:OMEMOBundleSignedPreKey = {
-					id: signedPreKey.keyId,
-					public_key: Base64.encode(Bytes.ofData(signedPreKey.keyPair.pubKey)),
-					signature: Base64.encode(Bytes.ofData(signedPreKey.signature)),
-			};
-			persistence.storeOmemoSignedPreKey(client.accountId(), stored_signed_prekey);
-			
+			trace("OMEMO: Built bundle");
+			persistence.storeOmemoSignedPreKey(client.accountId(), signedPreKey);
+
+			final public_signed_prekey = OMEMOBundleSignedPreKey.fromSignedPreKeyPair(signedPreKey);
 			this.bundle = {
 				identity_key: Base64.encode(Bytes.ofData(identityKeyPair.pubKey)),
 				device_id: deviceId,
 				prekeys: prekeys,
-				signed_prekey: stored_signed_prekey,
+				signed_prekey: public_signed_prekey,
 			};
 			return true;
 		});
@@ -410,4 +697,395 @@ class OMEMO {
 
 		return publicStoredPreKeys;
 	}
+
+	public function getDeviceId():Promise<Int> {
+		if(bundleLocalState.getCurrentState() == "ok") {
+			return Promise.resolve(this.bundle.device_id);
+		}
+
+		return new Promise((resolve, reject)->{
+			persistence.getOmemoId(client.accountId(), (deviceId) -> {
+				if(deviceId == null) {
+					// No device ID in storage yet. We need to trigger the
+					// bundle generation
+					bundleLocalState.once("enter/ok", (event) -> {
+						resolve(bundle.device_id);
+						return EventHandled;
+					});
+					bundleLocalState.event("missing");
+				} else {
+					resolve(deviceId);
+				}
+			});
+		});
+	}
+
+	private function decryptPayload(deviceId:Int, deviceKey:OMEMOPayloadKey, fromBare:String, payload:OMEMOPayload):Promise<BytesData> {
+		var cipher:SessionCipher;
+		final promCipher = new Promise<SessionCipher>((resolve, reject) -> {
+			if(deviceKey.prekey) {
+				// Incoming message used a prekey - build a new session between
+				// us and the sender
+				trace("OMEMO: Received an encrypted message using a prekey. Creating session...");
+				final promSession = buildSession(deviceId, fromBare, payload.sid);
+				promSession.then((session) -> {
+					getSessionCipher(deviceId, fromBare, payload.sid).then((cipher) -> {
+						resolve(cipher);
+					});
+				});
+				
+			} else {
+				trace("OMEMO: Received message from existing session");
+				getSessionCipher(deviceId, fromBare, payload.sid).then((cipher) -> {
+					resolve(cipher);
+				});
+			}
+		});
+
+		final promRawKeyWithTag = promCipher.then((cipher) -> {
+			if(deviceKey.prekey) {
+				return cipher.decryptPreKeyWhisperMessage(deviceKey.getRawKey());
+			} else {
+				return cipher.decryptWhisperMessage(deviceKey.getRawKey());
+			}
+		});
+		final promPayload = promRawKeyWithTag.then((rawKeyWithTag) -> {
+			return decryptPayloadWithKey(payload.getRawPayload(), rawKeyWithTag, payload.getRawIv());
+		});
+		return promPayload;
+			/*
+			final promBundle = getContactBundle(fromBare, payload.sid);
+			final promSenderSession = promBundle.then((bundle:OMEMOBundle) -> {
+				trace("OMEMO: Have contact bundle");
+				final sender = new SignalProtocolAddress(fromBare, payload.sid);
+				final contactPreKey = bundle.getRandomPreKey();
+				new SessionBuilder(signalStore, sender).processPreKey({
+					registrationId: payload.sid,
+					identityKey: Base64.decode(bundle.identity_key).getData(),
+					signedPreKey: {
+						keyId: bundle.signed_prekey.id,
+						publicKey: Base64.decode(bundle.signed_prekey.public_key).getData(),
+						signature: Base64.decode(bundle.signed_prekey.signature).getData(),
+					},
+					preKey: {
+						keyId: contactPreKey.keyId,
+						publicKey: Base64.decode(contactPreKey.pubKey).getData(),
+					},
+				});
+				trace("OMEMO: Processed prekey");
+				return sender;
+			});
+			final promPayload = promSenderSession.then((sender:SignalProtocolAddress) -> {
+				final sessionCipher = new SessionCipher(signalStore, sender);
+				final ciphertext = deviceKey.getRawKey();
+				
+				return sessionCipher.decryptPreKeyWhisperMessage(ciphertext).then(function(rawKeyWithTag:BytesData) {
+					return decryptPayloadWithKey(payload.getRawPayload(), rawKeyWithTag, payload.getRawIv());
+				});
+			});
+			return promPayload;
+		} else {
+			// Decrypt using existing session
+			final promCipher = getSessionCipher(deviceId, fromBare, payload.sid);
+			final promRawKeyWithTag = promCipher.then((cipher) -> {
+				return cipher.decryptWhisperMessage(deviceKey.getRawKey());
+			});
+			final promPayload = promRawKeyWithTag.then((rawKeyWithTag) -> {
+				return decryptPayloadWithKey(payload.getRawPayload(), rawKeyWithTag, payload.getRawIv());
+			});
+			return promPayload;
+		} */
+	}
+
+	private function sendKeyExchange(deviceId:Int, jid:String, rid:Int) {
+		trace("OMEMO: Preparing key exchange stanza...");
+		final emptyPayload = Bytes.alloc(32).toString();
+		final promEncryptedMessage = encryptPayloadWithNewKey(emptyPayload);
+
+		final promHeader = new Promise<Stanza>((resolve, reject) -> {
+			promEncryptedMessage.then((encryptionResult) -> {
+				buildOMEMOHeader(encryptionResult, deviceId, jid, [rid]).then(resolve, reject);
+			});
+		});
+
+		final promStanza = promHeader.then((header) -> {
+			final newStanza = new Stanza("message", { type: "chat" });
+			header.removeChildren("payload");
+			newStanza.addChild(header);
+			// FIXME: Probably need to add a store hint here
+			return newStanza;
+		});
+
+		return promStanza.then((stanza) -> {
+			trace("OMEMO: Sending key exchange stanza...");
+			client.sendStanza(stanza);
+			return stanza;
+		});
+	}
+
+	public function decryptMessage(stanza: Stanza):Promise<Stanza> {
+		final header = OMEMOPayload.fromMessageStanza(stanza);
+		return client.omemo.getDeviceId().then((deviceId:Int) -> {
+			final deviceKey = header.findKey(deviceId);
+			if(deviceKey == null) {
+				trace("OMEMO: Message not encrypted for our device (looked for "+deviceId+")");
+				stanza.removeChildren("encrypted", NS.OMEMO);
+				return Promise.resolve(stanza);
+			}
+			// FIXME: Identify correct JID for group chats
+			trace("OMEMO: Decrypting payload...");
+			final from = JID.parse(stanza.attr.get("from")).asBare();
+			final promPayload = decryptPayload(deviceId, deviceKey, from.asString(), header);
+			return promPayload.then((decryptedPayload:BytesData) -> {
+				if(decryptedPayload != null) {
+					stanza.removeChildren("body");
+					// FIXME: Verify valid UTF-8, etc.
+					stanza.textTag("body", Bytes.ofData(decryptedPayload).toString());
+					stanza.tag("decryption-status", {
+							xmlns: "https://snikket.org/protocol/sdk",
+							result: "success",
+							encryption: "eu.siacs.conversations.axolotl",
+					});
+					trace("OMEMO: Payload decrypted OK!");
+				} else {
+					trace("OMEMO: Decrypted payload is null?");
+				}
+				return Promise.resolve(stanza);
+			}, (err:Any) -> {
+				trace("OMEMO: Failed to decrypt message: " + err);
+				stanza.tag("decryption-status", {
+					xmlns: "https://snikket.org/protocol/sdk",
+					result: "failure",
+					encryption: "eu.siacs.conversations.axolotl",
+					reason: "generic",
+					text: err,
+				});
+				buildSession(deviceId, from.asString(), header.sid).then((session) -> {
+					// Broken session? Send key to start new session...
+					sendKeyExchange(deviceId, from.asString(), header.sid);
+				});
+				return Promise.resolve(stanza);
+			});
+		});
+	}
+
+	private function decryptPayloadWithKey(rawPayload:BytesData, rawKeyWithTag:BytesData, rawIv:BytesData):Promise<BytesData> {
+		trace("OMEMO: Decrypting payload with key...");
+		#if js
+		// 16-byte key followed by 16-byte tag
+		final bRawKeyWithTag = Bytes.ofData(rawKeyWithTag);
+		final rawKey = bRawKeyWithTag.sub(0, 16).getData();
+		// Produce new buffer with payload, followed by appended tag
+		final payloadWithTag = Bytes.alloc(rawPayload.byteLength + 16);
+		payloadWithTag.blit(0, Bytes.ofData(rawPayload), 0, rawPayload.byteLength);
+		payloadWithTag.blit(rawPayload.byteLength, bRawKeyWithTag, 16, 16);
+		final subtle = Browser.window.crypto.subtle;
+
+		// We have to wrap subtle's js.lib.Promise in a thenshim Promise *shrug*
+		return new Promise((resolve, reject) -> {
+			subtle.importKey("raw", rawKey, keyAlgorithm, false, keyPurposeDecrypt).then((key) -> {
+				subtle.decrypt({
+					name: "AES-GCM",
+					iv: rawIv,
+				}, key, payloadWithTag.getData()).then(resolve, reject);
+			});
+		});
+		#else
+		throw new haxe.exceptions.NotImplementedException();
+		#end
+	}
+
+	private function encryptPayloadWithNewKey(plaintext:String):Promise<OMEMOEncryptionResult> {
+		#if js
+		final subtle = Browser.window.crypto.subtle;
+		final encryptedPayload = new OMEMOEncryptionResult();
+		return new Promise((resolve, reject) -> {
+			encryptedPayload.iv = Browser.window.crypto.getRandomValues(new js.lib.Uint8Array(12)).buffer;
+
+			subtle.generateKey(keyAlgorithm, true, keyPurposeEncrypt).then((generatedKey) -> {
+				subtle.encrypt({
+					name: "AES-GCM",
+					iv: encryptedPayload.iv,
+				}, generatedKey, Bytes.ofString(plaintext).getData()).then((encryptionResult:BytesData) -> {
+					// Process result of encryption
+					final encryptedBytes = Bytes.ofData(encryptionResult);
+					final ciphertextLength = encryptionResult.byteLength - 16; // Exclude GCM tag
+					encryptedPayload.ciphertext = encryptedBytes.sub(0, ciphertextLength).getData();
+					encryptedPayload.tag = encryptedBytes.sub(ciphertextLength, 16).getData();
+					// Get the raw key data for the payload
+					new Promise((resolveKey, rejectKey) -> {
+						subtle.exportKey("raw", generatedKey).then(resolveKey, rejectKey);
+					}).then((exportedKey:BytesData) -> {
+						encryptedPayload.key = exportedKey;
+						resolve(encryptedPayload);
+					});
+				});
+			});	
+		});
+		#else
+		throw new haxe.exceptions.NotImplementedException();
+		#end
+	}
+
+	private function getContactBundle(jid:String, deviceId:Int):Promise<OMEMOBundle> {
+		final node = "eu.siacs.conversations.axolotl.bundles:"+Std.string(deviceId);
+		final query = new PubsubGet(jid, node);
+		return new Promise<OMEMOBundle>((resolve, reject) -> {
+			query.onFinished(() -> {
+				resolve(bundleFromPubsubItems(query.getResult(), deviceId));
+			});
+			client.sendQuery(query);
+		});
+	}
+
+	private function getContactDevices(jid:JID):Promise<Array<Int>> {
+		return new Promise((resolve, reject) -> {
+			// FIXME: Use local storage
+			final deviceListGet = new PubsubGet(jid.asString(), "eu.siacs.conversations.axolotl.devicelist");
+			deviceListGet.onFinished(() -> {
+				final devices = deviceIdsFromPubsubItems(deviceListGet.getResult());
+				if(devices != null) {
+					resolve(devices??[]);
+				} else {
+					reject("no-devices");
+				}
+			});
+			client.sendQuery(deviceListGet);
+		});
+	}
+
+	public function encryptMessage(recipient:JID, stanza:Stanza):Promise<Stanza> {
+		final promEncryptedMessage = encryptPayloadWithNewKey(stanza.getChildText("body"));
+
+		final promDeviceId = this.getDeviceId();
+
+		final promRecipientDevices = getContactDevices(recipient);
+
+		final promHeader = new Promise<Stanza>((resolve, reject) -> {
+			promDeviceId.then((deviceId) -> {
+				promRecipientDevices.then((recipientDevices) -> {
+					promEncryptedMessage.then((encryptionResult) -> {
+						buildOMEMOHeader(encryptionResult, deviceId, recipient.asString(), recipientDevices).then(resolve, reject);
+					});
+				});
+			});
+		});
+
+		final promStanza = promHeader.then((header) -> {
+			final newStanza = stanza.clone();
+			newStanza.removeChildren("body");
+			newStanza.addChild(header);
+			newStanza.textTag("encryption", "", { xmlns: "urn:xmpp:eme:0", namespace: "eu.siacs.conversations.axolotl" });
+			newStanza.textTag("body", "I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo");
+			return newStanza;
+		});
+
+		return promStanza;
+	}
+
+	private function buildSession(sid:Int, jid:String, rid:Int):Promise<SignalSession> {
+		final address = new SignalProtocolAddress(jid, rid);
+		final promBundle = getContactBundle(jid, rid);
+		trace("OMEMO: Building session (fetching bundle)...");
+		final promSession = promBundle.then((bundle:OMEMOBundle) -> {
+			trace("OMEMO: Fetched bundle");
+			final contactPreKey = bundle.getRandomPreKey();
+			return new SessionBuilder(signalStore, address).processPreKey({
+				registrationId: sid,
+				identityKey: Base64.decode(bundle.identity_key).getData(),
+				signedPreKey: {
+					keyId: bundle.signed_prekey.id,
+					publicKey: Base64.decode(bundle.signed_prekey.public_key).getData(),
+					signature: Base64.decode(bundle.signed_prekey.signature).getData(),
+				},
+				preKey: {
+					keyId: contactPreKey.keyId,
+					publicKey: Base64.decode(contactPreKey.pubKey).getData(),
+				},
+			});
+		}).then((_) -> {
+			trace("OMEMO: Built session!");
+			return signalStore.loadSession(address);
+		}, (err:Any) -> {
+			trace("OMEMO: Failed to build session: "+err);
+			return signalStore.loadSession(address);
+		});
+
+		return promSession;
+	}
+
+	private function getSessionCipher(sid:Int, jid:String, rid:Int):Promise<SessionCipher> {
+		final address = new SignalProtocolAddress(jid, rid);
+		final promSession = signalStore.loadSession(address);
+
+		// Load or start a session
+		final promReadySession = promSession.then((session) -> {
+			if(session == null) {
+				trace("OMEMO: No session for "+address.toString());
+				return buildSession(sid, jid, rid);
+			}
+			return session;
+		});
+
+		final promCipher = promReadySession.then((session) -> {
+			return new SessionCipher(signalStore, address);
+		});
+
+		return promCipher;
+	}
+
+	private function getRecipientSessions(sid:Int, jid:String, deviceList:Array<Int>):Promise<Array<SessionCipher>> {
+		return PromiseTools.all([
+			for (rid in deviceList) {
+				getSessionCipher(sid, jid, rid);
+			}
+		]);
+	}
+
+	private function encryptPayloadKeyForSession(encryptionResult:OMEMOEncryptionResult, sessionCipher:SessionCipher):Promise<SignalCipherText> {
+		final keyWithTag = encryptionResult.getKeyWithTag();
+		return sessionCipher.encrypt(keyWithTag);
+	}
+
+	private function buildOMEMOHeader(encryptionResult:OMEMOEncryptionResult, sid:Int, jid:String, deviceList:Array<Int>):Promise<Stanza> {
+		final promKeys = [
+			for(rid in deviceList) {
+				final promSessionCipher = getSessionCipher(sid, jid, rid);
+				promSessionCipher.then((sessionCipher) -> {
+					return encryptPayloadKeyForSession(encryptionResult, sessionCipher).then((encryptedKey) -> {
+						final payloadKey:OMEMOPayloadKey = {
+							rid: rid,
+							prekey: encryptedKey.type == 3,
+#if js
+							// Haxe cannot natively convert this string to a byte array. It only supports two
+							// encodings - 'UTF8' and 'RawNative'. The former wrongly tries to interpret
+							// the binary data as UTF-8 sequences, and the latter translates each character
+							// to a pair of bytes (since JS uses UTF-16).
+							encodedKey: Browser.window.btoa(encryptedKey.body)
+#else
+							encodedKey: Base64.encode(Bytes.ofString(encryptedKey.body, RawNative))
+#end
+						};
+						return payloadKey;
+					});
+				});
+			}
+		];
+
+		final promHeader = new Promise((resolve, reject) -> {
+			PromiseTools.all(promKeys).then((recipientKeys) -> {
+				final header:OMEMOPayload = {
+					sid: sid,
+					keys: recipientKeys,
+					encodedIv: Base64.encode(Bytes.ofData(encryptionResult.iv)),
+					encodedPayload: Base64.encode(Bytes.ofData(encryptionResult.ciphertext)),
+				};
+				resolve(header);
+			});
+		});
+
+		return promHeader.then((header) -> {
+			return header.toXml();
+		});
+	}
 }
diff --git a/snikket/Persistence.hx b/snikket/Persistence.hx
index 8b3a573..7c920fc 100644
--- a/snikket/Persistence.hx
+++ b/snikket/Persistence.hx
@@ -49,11 +49,14 @@ interface Persistence {
 	public function storeOmemoDeviceList(identifier:String, deviceIds:Array<Int>):Void;
 	public function storeOmemoPreKey(identifier:String, keyId:Int, keyPair:PreKeyPair):Void;
 	public function getOmemoPreKey(identifier:String, keyId:Int, callback: (PreKeyPair)->Void):Void;
-	public function storeOmemoSignedPreKey(login:String, signedPreKey:OMEMOBundleSignedPreKey):Void;
-	public function getOmemoSignedPreKey(login:String, keyId:Int, callback: (OMEMOBundleSignedPreKey)->Void):Void;
+	public function removeOmemoPreKey(identifier:String, keyId:Int):Void;
+	public function storeOmemoSignedPreKey(login:String, signedPreKey:SignedPreKey):Void;
+	public function getOmemoSignedPreKey(login:String, keyId:Int, callback: (SignedPreKey)->Void):Void;
 	public function getOmemoPreKeys(login:String, callback: (Array<PreKeyPair>)->Void):Void;
 	public function storeOmemoContactIdentityKey(account:String, address:String, identityKey:IdentityPublicKey):Void;
 	public function getOmemoContactIdentityKey(account:String, address:String, callback:(IdentityPublicKey)->Void):Void;
+	public function getOmemoSession(account:String, address:String, callback:(SignalSession)->Void):Void;
+	public function storeOmemoSession(account:String, address:String, session:SignalSession):Void;
 #end
 
 
diff --git a/snikket/persistence/Dummy.hx b/snikket/persistence/Dummy.hx
index 0302231..e9abe0e 100644
--- a/snikket/persistence/Dummy.hx
+++ b/snikket/persistence/Dummy.hx
@@ -156,6 +156,8 @@ class Dummy implements Persistence {
 	public function storeOmemoPreKey(identifier:String, keyId:Int, keyPair:PreKeyPair):Void { }
 	@HaxeCBridge.noemit
 	public function getOmemoPreKey(identifier:String, keyId:Int, callback: (PreKeyPair)->Void):Void { }
+	@HaxeCBridge.noemit
+	public function removeOmemoPreKey(identifier:String, keyId:Int):Void { }
 
 	@HaxeCBridge.noemit
 	public function storeOmemoIdentityKey(login:String, keypair:IdentityKeyPair):Void { }
@@ -163,9 +165,9 @@ class Dummy implements Persistence {
 	public function getOmemoIdentityKey(login:String, callback: (IdentityKeyPair)->Void):Void { }
 
 	@HaxeCBridge.noemit
-	public function storeOmemoSignedPreKey(login:String, signedPreKey:OMEMOBundleSignedPreKey):Void { }
+	public function storeOmemoSignedPreKey(login:String, signedPreKey:SignedPreKey):Void { }
 	@HaxeCBridge.noemit
-	public function getOmemoSignedPreKey(login:String, keyId:Int, callback: (OMEMOBundleSignedPreKey)->Void):Void { }
+	public function getOmemoSignedPreKey(login:String, keyId:Int, callback: (SignedPreKey)->Void):Void { }
 
 	@HaxeCBridge.noemit
 	public function getOmemoPreKeys(login:String, callback: (Array<PreKeyPair>)->Void):Void { }
@@ -174,5 +176,10 @@ class Dummy implements Persistence {
 	public function storeOmemoContactIdentityKey(account:String, address:String, identityKey:IdentityPublicKey):Void { }
 	@HaxeCBridge.noemit
 	public function getOmemoContactIdentityKey(account:String, address:String, callback:(IdentityPublicKey)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoSession(account:String, address:String, callback:(SignalSession)->Void):Void { }
+	@HaxeCBridge.noemit
+	public function storeOmemoSession(account:String, address:String, session:SignalSession):Void { }
 #end
 }
diff --git a/snikket/persistence/IDB.js b/snikket/persistence/IDB.js
index bfefeb1..08d0219 100644
--- a/snikket/persistence/IDB.js
+++ b/snikket/persistence/IDB.js
@@ -50,14 +50,34 @@ export default (dbname, media, tokenize, stemmer) => {
 				const reactions = upgradeDb.createObjectStore("reactions", { keyPath: ["account", "chatId", "senderId", "updateId"] });
 				reactions.createIndex("senders", ["account", "chatId", "messageId", "senderId", "timestamp"]);
 			}
+			if (!db.objectStoreNames.contains("omemo_identities")) {
+				upgradeDb.createObjectStore("omemo_identities", { keyPath: ["account", "address"] });
+			}
+			if (!db.objectStoreNames.contains("omemo_prekeys")) {
+				upgradeDb.createObjectStore("omemo_prekeys", { keyPath: ["account", "keyId"] });
+			}
+			if (!db.objectStoreNames.contains("omemo_sessions")) {
+				upgradeDb.createObjectStore("omemo_sessions", { keyPath: ["account", "address"] });
+			}
 		};
 		dbOpenReq.onsuccess = (event) => {
 			db = event.target.result;
-			window.db = db;
-			if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services") || !db.objectStoreNames.contains("reactions")) {
-				db.close();
-				openDb(db.version + 1);
-				return;
+			//window.db = db;
+			const storeNames = [
+				"messages",
+				"keyvaluepairs",
+				"chats",
+				"services",
+				"reactions",
+				"omemo_identities",
+				"omemo_sessions",
+			];
+			for(let storeName of storeNames) {
+				if(!db.objectStoreNames.contains(storeName)) {
+					db.close();
+					openDb(db.version + 1);
+					return;
+				}
 			}
 		};
 	}
@@ -604,22 +624,22 @@ export default (dbname, media, tokenize, stemmer) => {
 			}
 		},
 
-		storeOmemoId: function(login, omemoId) {
+		storeOmemoId: function(account, omemoId) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			store.put(omemoId, "omemo:id:" + login).onerror = console.error;
+			store.put(omemoId, "omemo:id:" + account).onerror = console.error;
 		},
 
-		storeOmemoIdentityKey: function (login, keypair) {
+		storeOmemoIdentityKey: function (account, keypair) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			store.put(keypair, "omemo:key:" + login).onerror = console.error;
+			store.put(keypair, "omemo:key:" + account).onerror = console.error;
 		},
 
-		storeOmemoDeviceList: function (identifier, deviceIds) {
+		storeOmemoDeviceList: function (chatId, deviceIds) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			const key = "omemo:devices:"+identifier;
+			const key = "omemo:devices:"+chatId;
 			if(deviceIds.length>0) {
 				store.put(deviceIds, key);
 			} else {
@@ -627,10 +647,10 @@ export default (dbname, media, tokenize, stemmer) => {
 			}
 		},
 
-		getOmemoDeviceList: function (identifier, callback) {
+		getOmemoDeviceList: function (chatId, callback) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			promisifyRequest(store.get("omemo:devices:"+identifier)).then((result) => {
+			promisifyRequest(store.get("omemo:devices:"+chatId)).then((result) => {
 				if (result === undefined) {
 					callback([]);
 				} else {
@@ -642,20 +662,27 @@ export default (dbname, media, tokenize, stemmer) => {
 			});
 		},
 
-		storeOmemoPreKey: function (login, keyId, keyPair) {
+		storeOmemoPreKey: function (account, keyId, keyPair) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
 			const storedKeyPair = {
 				"privKey": arrayBufferToBase64(keyPair.privKey),
 				"pubKey": arrayBufferToBase64(keyPair.pubKey),
 			};
-			store.put(storedKeyPair, "omemo:prekeys:"+login+":"+keyId.toString());
+			store.put(storedKeyPair, "omemo:prekeys:"+account+":"+keyId.toString());
 		},
 
-		getOmemoPreKey: function (login, keyId, callback) {
+		removeOmemoPreKey: function (account, keyId) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			promisifyRequest(store.get("omemo:prekeys:"+login+":"+keyId.toString())).then((result) => {
+			const keyName = "omemo:prekeys:"+account+":"+keyId.toString();
+			store.delete(keyName);
+		},
+
+		getOmemoPreKey: function (account, keyId, callback) {
+			const tx = db.transaction(["keyvaluepairs"], "readwrite");
+			const store = tx.objectStore("keyvaluepairs");
+			promisifyRequest(store.get("omemo:prekeys:"+account+":"+keyId.toString())).then((result) => {
 				if(result === undefined) {
 					callback(null);
 				} else {
@@ -670,10 +697,10 @@ export default (dbname, media, tokenize, stemmer) => {
 			});
 		},
 
-		getOmemoPreKeys: function (login, callback) {
+		getOmemoPreKeys: function (account, callback) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			const prefix = "omemo:prekeys:"+login+":";
+			const prefix = "omemo:prekeys:"+account+":";
 			const keyRange = IDBKeyRange.bound(prefix, prefix + '\uffff');
 
 			const prekeys = [];
@@ -738,10 +765,10 @@ export default (dbname, media, tokenize, stemmer) => {
 			});
 		},
 
-		getOmemoId: function(login, callback) {
-			const tx = db.transaction(["keyvaluepairs"], "readwrite");
+		getOmemoId: function(account, callback) {
+			const tx = db.transaction(["keyvaluepairs"], "readonly");
 			const store = tx.objectStore("keyvaluepairs");
-			promisifyRequest(store.get("omemo:id:"+login)).then((result) => {
+			promisifyRequest(store.get("omemo:id:"+account)).then((result) => {
 				callback(result);
 			}).catch((e) => {
 				console.error(e);
@@ -749,10 +776,10 @@ export default (dbname, media, tokenize, stemmer) => {
 			});
 		},
 
-		getOmemoIdentityKey: function(login, callback) {
-			const tx = db.transaction(["keyvaluepairs"], "readwrite");
+		getOmemoIdentityKey: function(account, callback) {
+			const tx = db.transaction(["keyvaluepairs"], "readonly");
 			const store = tx.objectStore("keyvaluepairs");
-			promisifyRequest(store.get("omemo:key:"+login)).then((result) => {
+			promisifyRequest(store.get("omemo:key:"+account)).then((result) => {
 				callback(result);
 			}).catch((e) => {
 				console.error(e);
@@ -760,21 +787,42 @@ export default (dbname, media, tokenize, stemmer) => {
 			});
 		},
 
-		getOmemoSignedPreKey: function(login, keyId, callback) {
-			const tx = db.transaction(["keyvaluepairs"], "readwrite");
+		getOmemoSignedPreKey: function(account, keyId, callback) {
+			const tx = db.transaction(["keyvaluepairs"], "readonly");
 			const store = tx.objectStore("keyvaluepairs");
-			promisifyRequest(store.get("omemo:signed-prekey:"+login+":"+keyId.toString())).then((result) => {
-				callback(result);
+			const dbKey = "omemo:signed-prekey:"+account+":"+keyId.toString();
+			console.log("OMEMO: Fetching signed prekey " + dbKey);
+			promisifyRequest(store.get(dbKey)).then((result) => {
+				if(!result) {
+					callback(null);
+				} else {
+					console.log("OMEMO: Loaded signed prekey " + dbKey);
+					callback({
+						keyId: keyId,
+						keyPair: {
+							privKey: base64ToArrayBuffer(result.privKey),
+							pubKey: base64ToArrayBuffer(result.pubKey),
+						},
+						signature: base64ToArrayBuffer(result.signature),
+					});
+				}
 			}).catch((e) => {
-				console.error(e);
+				console.error("OMEMO: Error loading signed prekey " + dbKey, e);
 				callback(null);
 			});
 		},
 
-		storeOmemoSignedPreKey: function (login, signedKey) {
+		storeOmemoSignedPreKey: function (account, signedKey) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			store.put(signedKey, "omemo:signed-prekey:"+login+":"+signedKey.id.toString());
+			const dbKey = "omemo:signed-prekey:"+account+":"+signedKey.keyId.toString();
+			console.log("OMEMO: Storing signed prekey", dbKey);
+			const storedKey = {
+				privKey: arrayBufferToBase64(signedKey.keyPair.privKey),
+				pubKey: arrayBufferToBase64(signedKey.keyPair.pubKey),
+				signature: arrayBufferToBase64(signedKey.signature),
+			};
+			store.put(storedKey, dbKey);
 		},
 
 		removeAccount(account, completely) {
@@ -866,6 +914,61 @@ export default (dbname, media, tokenize, stemmer) => {
 			}
 		},
 
+		// Return the IdentityKey stored for the given address
+		// Opposite of storeOmemoContactIdentityKey()
+		getOmemoContactIdentityKey: function (account, address, callback) {
+			const tx = db.transaction(["omemo_identities"], "readonly");
+			const store = tx.objectStore("omemo_identities");
+			promisifyRequest(store.get([account, address])).then((result) => {
+				if(!result) {
+					callback(undefined);
+				} else {
+					callback(base64ToArrayBuffer(result.pubKey));
+				}
+			}).catch((e) => {
+				console.error(e);
+				callback(undefined);
+			});
+		},
+
+		storeOmemoContactIdentityKey: function (account, address, identityKey) {
+			const tx = db.transaction(["omemo_identities"], "readwrite");
+			const store = tx.objectStore("omemo_identities");
+			promisifyRequest(store.put({
+				account: account,
+				address: address,
+				pubKey: arrayBufferToBase64(identityKey),
+			})).catch((e) => {
+				console.error("Failed to store contact identity key: " + e);
+			});
+		},
+
+		getOmemoSession: function (account, address, callback) {
+			const tx = db.transaction(["omemo_sessions"], "readonly");
+			const store = tx.objectStore("omemo_sessions");
+			promisifyRequest(store.get([account, address])).then((result) => {
+				if(!result) {
+					callback(undefined);
+				} else {
+					callback(result.session);
+				}
+			}).catch((e) => {
+				console.error("Failed to load OMEMO session: " + e);
+			});
+		},
+
+		storeOmemoSession: function (account, address, session) {
+			const tx = db.transaction(["omemo_sessions"], "readwrite");
+			const store = tx.objectStore("omemo_sessions");
+			promisifyRequest(store.put({
+				account: account,
+				address: address,
+				session: session,
+			})).catch((e) => {
+				console.error("Failed to store OMEMO session: " + e);
+			});
+		},
+
 		get(k) {
 			const tx = db.transaction(["keyvaluepairs"], "readonly");
 			const store = tx.objectStore("keyvaluepairs");
diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx
index bd1c68d..0be48a1 100644
--- a/snikket/persistence/Sqlite.hx
+++ b/snikket/persistence/Sqlite.hx
@@ -16,6 +16,7 @@ import snikket.Reaction;
 import snikket.ReactionUpdate;
 #if !NO_OMEMO
 import snikket.OMEMO;
+using snikket.SignalProtocol;
 #end
 
 using Lambda;
@@ -270,7 +271,8 @@ class Sqlite implements Persistence implements KeyValueStore {
 						presence.mucUser == null || Config.constrainedMemoryMode ? null : Stanza.parse(presence.mucUser)
 					);
 				}
-				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, row.capsObj, Reflect.field(row, "class")));
+				// FIXME: Empty OMEMO contact device ids hardcoded in next line
+				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, row.capsObj, [], Reflect.field(row, "class")));
 			}
 			return chats;
 		});
@@ -853,5 +855,47 @@ class Sqlite implements Persistence implements KeyValueStore {
 
 	@HaxeCBridge.noemit
 	public function storeOmemoId(login:String, omemoId:Int):Void { }
+
+	@HaxeCBridge.noemit
+	public function storeOmemoIdentityKey(login:String, keypair:IdentityKeyPair):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoIdentityKey(login:String, callback: (IdentityKeyPair)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoDeviceList(identifier:String, callback: (Array<Int>)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function storeOmemoDeviceList(identifier:String, deviceIds:Array<Int>):Void { }
+
+	@HaxeCBridge.noemit
+	public function storeOmemoPreKey(identifier:String, keyId:Int, keyPair:PreKeyPair):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoPreKey(identifier:String, keyId:Int, callback: (PreKeyPair)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function removeOmemoPreKey(identifier:String, keyId:Int):Void { }
+
+	@HaxeCBridge.noemit
+	public function storeOmemoSignedPreKey(login:String, signedPreKey:SignedPreKey):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoSignedPreKey(login:String, keyId:Int, callback: (SignedPreKey)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoPreKeys(login:String, callback: (Array<PreKeyPair>)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function storeOmemoContactIdentityKey(account:String, address:String, identityKey:IdentityPublicKey):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoContactIdentityKey(account:String, address:String, callback:(IdentityPublicKey)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function getOmemoSession(account:String, address:String, callback:(SignalSession)->Void):Void { }
+
+	@HaxeCBridge.noemit
+	public function storeOmemoSession(account:String, address:String, session:SignalSession):Void { }
 #end
 }