| author | Matthew Wild
<mwild1@gmail.com> 2025-04-18 18:32:47 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-09-29 13:43:03 UTC |
| parent | c05b84c80d092efc2094b963b832d9ab8fcee1da |
| 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 }