From d5ea5172a754848c10d061a4a9dd777f63ba71c1 Mon Sep 17 00:00:00 2001 From: Marvin W Date: Sat, 11 Mar 2017 01:29:38 +0100 Subject: Add OMEMO via Plugin --- plugins/omemo/CMakeLists.txt | 41 +++ plugins/omemo/src/database.vala | 80 ++++++ plugins/omemo/src/manager.vala | 270 +++++++++++++++++++ plugins/omemo/src/module.vala | 560 ++++++++++++++++++++++++++++++++++++++++ plugins/omemo/src/plugin.vala | 130 ++++++++++ 5 files changed, 1081 insertions(+) create mode 100644 plugins/omemo/CMakeLists.txt create mode 100644 plugins/omemo/src/database.vala create mode 100644 plugins/omemo/src/manager.vala create mode 100644 plugins/omemo/src/module.vala create mode 100644 plugins/omemo/src/plugin.vala (limited to 'plugins/omemo') diff --git a/plugins/omemo/CMakeLists.txt b/plugins/omemo/CMakeLists.txt new file mode 100644 index 00000000..fba75ab4 --- /dev/null +++ b/plugins/omemo/CMakeLists.txt @@ -0,0 +1,41 @@ +find_package(Vala REQUIRED) +find_package(PkgConfig REQUIRED) +include(${VALA_USE_FILE}) + +set(OMEMO_PACKAGES + gee-0.8 + gio-2.0 + glib-2.0 + gtk+-3.0 + gmodule-2.0 + sqlite3 +) + +pkg_check_modules(OMEMO REQUIRED ${OMEMO_PACKAGES}) + +vala_precompile(OMEMO_VALA_C +SOURCES + src/plugin.vala + src/module.vala + src/manager.vala + src/database.vala +CUSTOM_VAPIS + ${CMAKE_BINARY_DIR}/exports/signal-protocol.vapi + ${CMAKE_BINARY_DIR}/exports/xmpp-vala.vapi + ${CMAKE_BINARY_DIR}/exports/qlite.vapi + ${CMAKE_BINARY_DIR}/exports/dino.vapi +PACKAGES + ${OMEMO_PACKAGES} +OPTIONS + --target-glib=2.38 + ${GLOBAL_DEBUG_FLAGS} + --thread +) + +set(CFLAGS ${VALA_CFLAGS} ${OMEMO_CFLAGS}) +add_definitions(${CFLAGS}) +add_library(omemo SHARED ${OMEMO_VALA_C}) +add_dependencies(omemo dino-vapi signal-protocol-vapi) +target_link_libraries(omemo libdino signal-protocol-vala) +set_target_properties(omemo PROPERTIES PREFIX "") +set_target_properties(omemo PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins/) diff --git a/plugins/omemo/src/database.vala b/plugins/omemo/src/database.vala new file mode 100644 index 00000000..1216ca84 --- /dev/null +++ b/plugins/omemo/src/database.vala @@ -0,0 +1,80 @@ +using Gee; +using Sqlite; +using Qlite; + +using Dino.Entities; + +namespace Dino.Omemo { + +public class Database : Qlite.Database { + private const int VERSION = 0; + + public class IdentityTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column account_id = new Column.Integer("account_id") { unique = true, not_null = true }; + public Column device_id = new Column.Integer("device_id") { not_null = true }; + public Column identity_key_private_base64 = new Column.Text("identity_key_private_base64") { not_null = true }; + public Column identity_key_public_base64 = new Column.Text("identity_key_public_base64") { not_null = true }; + + protected IdentityTable(Database db) { + base(db, "identity"); + init({id, account_id, device_id, identity_key_private_base64, identity_key_public_base64}); + } + } + + public class SignedPreKeyTable : Table { + public Column identity_id = new Column.Integer("identity_id") { not_null = true }; + public Column signed_pre_key_id = new Column.Integer("signed_pre_key_id") { not_null = true }; + public Column record_base64 = new Column.Text("record_base64") { not_null = true }; + + protected SignedPreKeyTable(Database db) { + base(db, "signed_pre_key"); + init({identity_id, signed_pre_key_id, record_base64}); + unique({identity_id, signed_pre_key_id}); + } + } + + public class PreKeyTable : Table { + public Column identity_id = new Column.Integer("identity_id") { not_null = true }; + public Column pre_key_id = new Column.Integer("pre_key_id") { not_null = true }; + public Column record_base64 = new Column.Text("record_base64") { not_null = true }; + + protected PreKeyTable(Database db) { + base(db, "pre_key"); + init({identity_id, pre_key_id, record_base64}); + unique({identity_id, pre_key_id}); + } + } + + public class SessionTable : Table { + public Column identity_id = new Column.Integer("identity_id") { not_null = true }; + public Column address_name = new Column.Text("name") { not_null = true }; + public Column device_id = new Column.Integer("device_id") { not_null = true }; + public Column record_base64 = new Column.Text("record_base64") { not_null = true }; + + protected SessionTable(Database db) { + base(db, "session"); + init({identity_id, address_name, device_id, record_base64}); + unique({identity_id, address_name, device_id}); + } + } + public IdentityTable identity { get; private set; } + public SignedPreKeyTable signed_pre_key { get; private set; } + public PreKeyTable pre_key { get; private set; } + public SessionTable session { get; private set; } + + public Database(string fileName) { + base(fileName, VERSION); + identity = new IdentityTable(this); + signed_pre_key = new SignedPreKeyTable(this); + pre_key = new PreKeyTable(this); + session = new SessionTable(this); + init({identity, signed_pre_key, pre_key, session}); + } + + public override void migrate(long oldVersion) { + // new table columns are added, outdated columns are still present + } +} + +} \ No newline at end of file diff --git a/plugins/omemo/src/manager.vala b/plugins/omemo/src/manager.vala new file mode 100644 index 00000000..69a69d9c --- /dev/null +++ b/plugins/omemo/src/manager.vala @@ -0,0 +1,270 @@ +using Dino.Entities; +using Signal; +using Qlite; +using Xmpp; +using Gee; + +namespace Dino.Omemo { + +public class Manager : StreamInteractionModule, Object { + public const string id = "omemo_manager"; + + private StreamInteractor stream_interactor; + private Database db; + private ArrayList to_send_after_devicelist = new ArrayList(); + private ArrayList to_send_after_session = new ArrayList(); + + private Manager(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + + stream_interactor.account_added.connect(on_account_added); + MessageManager.get_instance(stream_interactor).pre_message_received.connect(on_pre_message_received); + MessageManager.get_instance(stream_interactor).pre_message_send.connect(on_pre_message_send); + } + + private void on_pre_message_received(Entities.Message message, Xmpp.Message.Stanza message_stanza, Conversation conversation) { + if (MessageFlag.get_flag(message_stanza) != null && MessageFlag.get_flag(message_stanza).decrypted) { + message.encryption = Encryption.OMEMO; + } + } + + private void on_pre_message_send(Entities.Message message, Xmpp.Message.Stanza message_stanza, Conversation conversation) { + if (message.encryption == Encryption.OMEMO) { + Module module = Module.get_module(stream_interactor.get_stream(conversation.account)); + EncryptStatus status = module.encrypt(message_stanza, conversation.account.bare_jid.to_string()); + if (status.other_failure > 0 || (status.other_lost == status.other_devices && status.other_devices > 0)) { + message.marked = Entities.Message.Marked.WONTSEND; + } else if (status.other_unknown > 0 || status.own_devices == 0) { + message.marked = Entities.Message.Marked.UNSENT; + } else if (!status.encrypted) { + message.marked = Entities.Message.Marked.WONTSEND; + } + + if (status.other_unknown > 0) { + bool cont = true; + lock(to_send_after_session) { + foreach(Entities.Message msg in to_send_after_session) { + if (msg.counterpart.bare_jid.to_string() == message.counterpart.bare_jid.to_string()) cont = false; + } + to_send_after_session.add(message); + } + if (cont) module.start_sessions_with(stream_interactor.get_stream(conversation.account), message.counterpart.bare_jid.to_string()); + } + if (status.own_unknown > 0) { + module.start_sessions_with(stream_interactor.get_stream(conversation.account), conversation.account.bare_jid.to_string()); + } + if (status.own_devices == 0) { + lock (to_send_after_session) { + to_send_after_devicelist.add(message); + } + } + } + } + + private void on_account_added(Account account) { + stream_interactor.module_manager.get_module(account, Module.IDENTITY).store_created.connect((context, store) => on_store_created(account, context, store)); + stream_interactor.module_manager.get_module(account, Module.IDENTITY).device_list_loaded.connect(() => on_device_list_loaded(account)); + stream_interactor.module_manager.get_module(account, Module.IDENTITY).session_started.connect((jid, device_id) => on_session_started(account, jid)); + } + + private void on_session_started(Account account, string jid) { + lock(to_send_after_session) { + Iterator iter = to_send_after_session.iterator(); + while (iter.next()) { + Entities.Message msg = iter.get(); + if (msg.account.bare_jid.to_string() == account.bare_jid.to_string() && msg.counterpart.bare_jid.to_string() == jid) { + Entities.Conversation conv = ConversationManager.get_instance(stream_interactor).get_conversation(msg.counterpart, account); + MessageManager.get_instance(stream_interactor).send_xmpp_message(msg, conv, true); + iter.remove(); + } + } + } + } + + private void on_device_list_loaded(Account account) { + lock(to_send_after_devicelist) { + Iterator iter = to_send_after_devicelist.iterator(); + while (iter.next()) { + Entities.Message msg = iter.get(); + if (msg.account.bare_jid.to_string() == account.bare_jid.to_string()) { + Entities.Conversation conv = ConversationManager.get_instance(stream_interactor).get_conversation(msg.counterpart, account); + MessageManager.get_instance(stream_interactor).send_xmpp_message(msg, conv, true); + iter.remove(); + } + } + } + } + + private void on_store_created(Account account, Context context, Store store) { + Qlite.Row? row = null; + try { + row = db.identity.row_with(db.identity.account_id, account.id); + } catch (Error e) { + // Ignore error + } + int identity_id = -1; + + if (row == null) { + // OMEMO not yet initialized, starting with empty base + store.identity_key_store.local_registration_id = Random.int_range(1, int32.MAX); + + Signal.ECKeyPair key_pair = context.generate_key_pair(); + store.identity_key_store.identity_key_private = key_pair.private.serialize(); + store.identity_key_store.identity_key_public = key_pair.public.serialize(); + + try { + identity_id = (int) db.identity.insert().or("REPLACE") + .value(db.identity.account_id, account.id) + .value(db.identity.device_id, (int) store.local_registration_id) + .value(db.identity.identity_key_private_base64, Base64.encode(store.identity_key_store.identity_key_private)) + .value(db.identity.identity_key_public_base64, Base64.encode(store.identity_key_store.identity_key_public)) + .perform(); + } catch (Error e) { + // Ignore error + } + } else { + store.identity_key_store.local_registration_id = row[db.identity.device_id]; + store.identity_key_store.identity_key_private = Base64.decode(row[db.identity.identity_key_private_base64]); + store.identity_key_store.identity_key_public = Base64.decode(row[db.identity.identity_key_public_base64]); + identity_id = row[db.identity.id]; + } + + if (identity_id >= 0) { + store.signed_pre_key_store = new BackedSignedPreKeyStore(db, identity_id); + store.pre_key_store = new BackedPreKeyStore(db, identity_id); + store.session_store = new BackedSessionStore(db, identity_id); + } else { + print(@"WARN: OMEMO store for $(account.bare_jid) is not persisted"); + } + } + + private class BackedSignedPreKeyStore : SimpleSignedPreKeyStore { + private Database db; + private int identity_id; + + public BackedSignedPreKeyStore(Database db, int identity_id) { + this.db = db; + this.identity_id = identity_id; + init(); + } + + private void init() { + foreach (Row row in db.signed_pre_key.select().with(db.signed_pre_key.identity_id, "=", identity_id)) { + store_signed_pre_key(row[db.signed_pre_key.signed_pre_key_id], Base64.decode(row[db.signed_pre_key.record_base64])); + } + + signed_pre_key_stored.connect(on_signed_pre_key_stored); + signed_pre_key_deleted.connect(on_signed_pre_key_deleted); + } + + public void on_signed_pre_key_stored(SignedPreKeyStore.Key key) { + db.signed_pre_key.insert().or("REPLACE") + .value(db.signed_pre_key.identity_id, identity_id) + .value(db.signed_pre_key.signed_pre_key_id, (int) key.key_id) + .value(db.signed_pre_key.record_base64, Base64.encode(key.record)) + .perform(); + } + + public void on_signed_pre_key_deleted(SignedPreKeyStore.Key key) { + db.signed_pre_key.delete() + .with(db.signed_pre_key.identity_id, "=", identity_id) + .with(db.signed_pre_key.signed_pre_key_id, "=", (int) key.key_id) + .perform(); + } + } + + private class BackedPreKeyStore : SimplePreKeyStore { + private Database db; + private int identity_id; + + public BackedPreKeyStore(Database db, int identity_id) { + this.db = db; + this.identity_id = identity_id; + init(); + } + + private void init() { + foreach (Row row in db.pre_key.select().with(db.pre_key.identity_id, "=", identity_id)) { + store_pre_key(row[db.pre_key.pre_key_id], Base64.decode(row[db.pre_key.record_base64])); + } + + pre_key_stored.connect(on_pre_key_stored); + pre_key_deleted.connect(on_pre_key_deleted); + } + + public void on_pre_key_stored(PreKeyStore.Key key) { + db.pre_key.insert().or("REPLACE") + .value(db.pre_key.identity_id, identity_id) + .value(db.pre_key.pre_key_id, (int) key.key_id) + .value(db.pre_key.record_base64, Base64.encode(key.record)) + .perform(); + } + + public void on_pre_key_deleted(PreKeyStore.Key key) { + db.pre_key.delete() + .with(db.pre_key.identity_id, "=", identity_id) + .with(db.pre_key.pre_key_id, "=", (int) key.key_id) + .perform(); + } + } + + private class BackedSessionStore : SimpleSessionStore { + private Database db; + private int identity_id; + + public BackedSessionStore(Database db, int identity_id) { + this.db = db; + this.identity_id = identity_id; + init(); + } + + private void init() { + Address addr = new Address(); + foreach (Row row in db.session.select().with(db.session.identity_id, "=", identity_id)) { + addr.name = row[db.session.address_name]; + addr.device_id = row[db.session.device_id]; + store_session(addr, Base64.decode(row[db.session.record_base64])); + } + + session_stored.connect(on_session_stored); + session_removed.connect(on_session_deleted); + } + + public void on_session_stored(SessionStore.Session session) { + db.session.insert().or("REPLACE") + .value(db.session.identity_id, identity_id) + .value(db.session.address_name, session.name) + .value(db.session.device_id, session.device_id) + .value(db.session.record_base64, Base64.encode(session.record)) + .perform(); + } + + public void on_session_deleted(SessionStore.Session session) { + db.session.delete() + .with(db.session.identity_id, "=", identity_id) + .with(db.session.address_name, "=", session.name) + .with(db.session.device_id, "=", session.device_id) + .perform(); + } + } + + public bool con_encrypt(Entities.Conversation conversation) { + return true; // TODO + } + + internal string get_id() { + return id; + } + + public static void start(StreamInteractor stream_interactor, Database db) { + Manager m = new Manager(stream_interactor, db); + stream_interactor.add_module(m); + } + + public static Manager? get_instance(StreamInteractor stream_interactor) { + return (Manager) stream_interactor.get_module(id); + } +} + +} \ No newline at end of file diff --git a/plugins/omemo/src/module.vala b/plugins/omemo/src/module.vala new file mode 100644 index 00000000..67651be5 --- /dev/null +++ b/plugins/omemo/src/module.vala @@ -0,0 +1,560 @@ +using Gee; +using Xmpp; +using Xmpp.Core; +using Xmpp.Xep; +using Signal; + +namespace Dino.Omemo { + +private const string NS_URI = "eu.siacs.conversations.axolotl"; +private const string NODE_DEVICELIST = NS_URI + ".devicelist"; +private const string NODE_BUNDLES = NS_URI + ".bundles"; +private const string NODE_VERIFICATION = NS_URI + ".verification"; + +private const int NUM_KEYS_TO_PUBLISH = 100; + +public class Module : XmppStreamModule { + private const string ID = "axolotl_module"; + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, ID); + + private Store store; + internal static Context context; + private bool device_list_loading = false; + private bool device_list_modified = false; + private Map> device_lists = new HashMap>(); + private Map> ignored_devices = new HashMap>(); + + public signal void store_created(Context context, Store store); + public signal void device_list_loaded(); + public signal void session_started(string jid, int device_id); + + public Module() { + lock(context) { + if (context == null) { + try { + context = new Context(true); + } catch (Error e) { + print(@"Error initializing axolotl: $(e.message)\n"); + } + } + } + } + + public EncryptStatus encrypt(Message.Stanza message, string self_bare_jid) { + EncryptStatus status = new EncryptStatus(); + if (context == null) return status; + try { + string name = get_bare_jid(message.to); + if (device_lists.get(name) == null || device_lists.get(self_bare_jid) == null) return status; + status.other_devices = device_lists.get(name).size; + status.own_devices = device_lists.get(self_bare_jid).size; + if (status.other_devices == 0) return status; + + uint8[] key = new uint8[16]; + context.randomize(key); + uint8[] iv = new uint8[16]; + context.randomize(iv); + + uint8[] ciphertext = aes_encrypt(Cipher.AES_GCM_NOPADDING, key, iv, message.body.data); + + StanzaNode header = null; + StanzaNode encrypted = new StanzaNode.build("encrypted", NS_URI).add_self_xmlns() + .put_node(header = new StanzaNode.build("header", NS_URI) + .put_attribute("sid", store.local_registration_id.to_string()) + .put_node(new StanzaNode.build("iv", NS_URI) + .put_node(new StanzaNode.text(Base64.encode(iv))))) + .put_node(new StanzaNode.build("payload", NS_URI) + .put_node(new StanzaNode.text(Base64.encode(ciphertext)))); + + Address address = new Address(); + address.name = name; + foreach(int32 device_id in device_lists[name]) { + if (is_ignored_device(name, device_id)) { + status.other_lost++; + continue; + } + try { + address.device_id = (int) device_id; + StanzaNode key_node = create_encrypted_key(key, address); + header.put_node(key_node); + status.other_success++; + } catch (Error e) { + if (e.code == ErrorCode.UNKNOWN) status.other_unknown++; + else status.other_failure++; + } + } + address.name = self_bare_jid; + foreach(int32 device_id in device_lists[self_bare_jid]) { + if (is_ignored_device(self_bare_jid, device_id)) { + status.own_lost++; + continue; + } + if (device_id != store.local_registration_id) { + address.device_id = (int) device_id; + try { + StanzaNode key_node = create_encrypted_key(key, address); + header.put_node(key_node); + status.own_success++; + } catch (Error e) { + if (e.code == ErrorCode.UNKNOWN) status.own_unknown++; + else status.own_failure++; + } + } + } + + message.stanza.put_node(encrypted); + message.body = "[This message is OMEMO encrypted]"; + status.encrypted = true; + } catch (Error e) { + print(@"Axolotl error while encrypting message: $(e.message)\n"); + } + return status; + } + + private StanzaNode create_encrypted_key(uint8[] key, Address address) throws GLib.Error { + SessionCipher cipher = store.create_session_cipher(address); + CiphertextMessage device_key = cipher.encrypt(key); + StanzaNode key_node = new StanzaNode.build("key", NS_URI) + .put_attribute("rid", address.device_id.to_string()) + .put_node(new StanzaNode.text(Base64.encode(device_key.serialized))); + if (device_key.type == CiphertextType.PREKEY) key_node.put_attribute("prekey", "true"); + return key_node; + } + + public override void attach(XmppStream stream) { + if (context == null) return; + Message.Module.require(stream); + Pubsub.Module.require(stream); + stream.get_module(Message.Module.IDENTITY).pre_received_message.connect(on_pre_received_message); + stream.get_module(Pubsub.Module.IDENTITY).add_filtered_notification(stream, NODE_DEVICELIST, on_devicelist, this); + this.store = context.create_store(); + store_created(context, store); + } + + private void on_pre_received_message(XmppStream stream, Message.Stanza message) { + StanzaNode? encrypted = message.stanza.get_subnode("encrypted", NS_URI); + if (encrypted == null) return; + MessageFlag flag = new MessageFlag(); + message.add_flag(flag); + StanzaNode? header = encrypted.get_subnode("header"); + if (header == null || header.get_attribute_int("sid") <= 0) return; + foreach (StanzaNode key_node in header.get_subnodes("key")) { + if (key_node.get_attribute_int("rid") == store.local_registration_id) { + try { + uint8[] key = null; + uint8[] ciphertext = Base64.decode(encrypted.get_subnode("payload").get_string_content()); + uint8[] iv = Base64.decode(header.get_subnode("iv").get_string_content()); + Address address = new Address(); + address.name = get_bare_jid(message.from); + address.device_id = header.get_attribute_int("sid"); + if (key_node.get_attribute_bool("prekey")) { + PreKeySignalMessage msg = context.deserialize_pre_key_signal_message(Base64.decode(key_node.get_string_content())); + SessionCipher cipher = store.create_session_cipher(address); + key = cipher.decrypt_pre_key_signal_message(msg); + } else { + SignalMessage msg = context.deserialize_signal_message(Base64.decode(key_node.get_string_content())); + SessionCipher cipher = store.create_session_cipher(address); + key = cipher.decrypt_signal_message(msg); + } + address.device_id = 0; // TODO: Hack to have address obj live longer + + + if (key != null && ciphertext != null && iv != null) { + if (key.length >= 32) { + int authtaglength = key.length - 16; + uint8[] new_ciphertext = new uint8[ciphertext.length + authtaglength]; + uint8[] new_key = new uint8[16]; + Memory.copy(new_ciphertext, ciphertext, ciphertext.length); + Memory.copy((uint8*)new_ciphertext + ciphertext.length, (uint8*)key + 16, authtaglength); + Memory.copy(new_key, key, 16); + ciphertext = new_ciphertext; + key = new_key; + } + + message.body = arr_to_str(aes_decrypt(Cipher.AES_GCM_NOPADDING, key, iv, ciphertext)); + flag.decrypted = true; + } + } catch (Error e) { + print(@"Axolotl error while decrypting message: $(e.message)\n"); + } + } + } + } + + private string arr_to_str(uint8[] arr) { + // null-terminate the array + uint8[] rarr = new uint8[arr.length+1]; + Memory.copy(rarr, arr, arr.length); + return (string)rarr; + } + + public void on_devicelist(XmppStream stream, string jid, string id, StanzaNode node) { + if (jid == get_bare_jid(Bind.Flag.get_flag(stream).my_jid) && store.local_registration_id != 0) { + lock (device_list_loading) { + if (!device_list_loading) { + device_list_loading = true; + GLib.Timeout.add_seconds(3, () => { + bool cont = false; + lock (device_lists) { + if (device_list_modified) { + cont = true; + device_list_modified = false; + } + } + if (!cont) { + lock (device_list_loading) { + device_list_loading = false; + device_list_loaded(); + } + } + return cont; + }); + } + } + + bool am_on_devicelist = false; + foreach (StanzaNode device_node in node.get_subnodes("device")) { + int device_id = device_node.get_attribute_int("id"); + if (store.local_registration_id == device_id) { + am_on_devicelist = true; + } + } + if (!am_on_devicelist) { + print(@"Not on device list, adding id\n"); + node.put_node(new StanzaNode.build("device", NS_URI).put_attribute("id", store.local_registration_id.to_string())); + stream.get_module(Pubsub.Module.IDENTITY).publish(stream, jid, NODE_DEVICELIST, NODE_DEVICELIST, id, node); + } else { + publish_bundles_if_needed(stream, jid); + } + } + lock(device_lists) { + device_list_modified = true; + device_lists[jid] = new ArrayList(); + foreach (StanzaNode device_node in node.get_subnodes("device")) { + device_lists[jid].add(device_node.get_attribute_int("id")); + } + } + } + + public void start_sessions_with(XmppStream stream, string bare_jid) { + if (!device_lists.has_key(bare_jid)) { + // TODO: manually request a device list + return; + } + Address address = new Address(); + address.name = bare_jid; + foreach(int32 device_id in device_lists[bare_jid]) { + if (!is_ignored_device(bare_jid, device_id)) { + address.device_id = device_id; + if (!store.contains_session(address)) { + start_session_with(stream, bare_jid, device_id); + } + } + } + address.device_id = 0; + } + + public void start_session_with(XmppStream stream, string bare_jid, int device_id) { + print(@"Asking for bundle from $bare_jid/$device_id\n"); + stream.get_module(Pubsub.Module.IDENTITY).request(stream, bare_jid, @"$NODE_BUNDLES:$device_id", new OtherBundleResponseListener(store, device_id)); + } + + public void ignore_device(string jid, int32 device_id) { + if (device_id <= 0) return; + lock (ignored_devices) { + if (!ignored_devices.has_key(jid)) { + ignored_devices[jid] = new ArrayList(); + } + ignored_devices[jid].add(device_id); + } + } + + public bool is_ignored_device(string jid, int32 device_id) { + if (device_id <= 0) return true; + lock (ignored_devices) { + return ignored_devices.has_key(jid) && ignored_devices[jid].contains(device_id); + } + } + + private class OtherBundleResponseListener : Pubsub.RequestResponseListener, Object { + private Store store; + private int device_id; + + public OtherBundleResponseListener(Store store, int device_id) { + this.store = store; + this.device_id = device_id; + } + + public void on_result(XmppStream stream, string jid, string? id, StanzaNode? node) { + bool fail = false; + if (node == null) { + // Device not registered, shouldn't exist + fail = true; + } else { + Bundle bundle = new Bundle(node); + int32 signed_pre_key_id = bundle.signed_pre_key_id; + ECPublicKey? signed_pre_key = bundle.signed_pre_key; + uint8[] signed_pre_key_signature = bundle.signed_pre_key_signature; + ECPublicKey? identity_key = bundle.identity_key; + + ArrayList pre_keys = bundle.pre_keys; + int pre_key_idx = Random.int_range(0, pre_keys.size); + int32 pre_key_id = pre_keys[pre_key_idx].key_id; + ECPublicKey? pre_key = pre_keys[pre_key_idx].key; + + if (signed_pre_key_id < 0 || signed_pre_key == null || identity_key == null || pre_key_id < 0 || pre_key == null) { + fail = true; + } else { + Address address = new Address(); + address.name = jid; + address.device_id = device_id; + try { + if (store.contains_session(address)) { + return; + } + SessionBuilder builder = store.create_session_builder(address); + builder.process_pre_key_bundle(create_pre_key_bundle(device_id, device_id, pre_key_id, pre_key, signed_pre_key_id, signed_pre_key, signed_pre_key_signature, identity_key)); + } catch (Error e) { + fail = true; + } + address.device_id = 0; // TODO: Hack to have address obj live longer + get_module(stream).session_started(jid, device_id); + } + } + if (fail) { + get_module(stream).ignore_device(jid, device_id); + } + } + } + + public void publish_bundles_if_needed(XmppStream stream, string jid) { + stream.get_module(Pubsub.Module.IDENTITY).request(stream, jid, @"$NODE_BUNDLES:$(store.local_registration_id)", new SelfBundleResponseListener(store)); + } + + private class SelfBundleResponseListener : Pubsub.RequestResponseListener, Object { + private Store store; + + public SelfBundleResponseListener(Store store) { + this.store = store; + } + + public void on_result(XmppStream stream, string jid, string? id, StanzaNode? node) { + Map keys = new HashMap(); + ECPublicKey identity_key = null; + IdentityKeyPair identity_key_pair = null; + int32 signed_pre_key_id = -1; + ECPublicKey signed_pre_key = null; + SignedPreKeyRecord signed_pre_key_record = null; + bool changed = false; + if (node == null) { + identity_key = store.identity_key_pair.public; + changed = true; + } else { + Bundle bundle = new Bundle(node); + foreach (Bundle.PreKey prekey in bundle.pre_keys) { + keys[prekey.key_id] = prekey.key; + } + identity_key = bundle.identity_key; + signed_pre_key_id = bundle.signed_pre_key_id;; + signed_pre_key = bundle.signed_pre_key; + } + + // Validate IdentityKey + if (store.identity_key_pair.public.compare(identity_key) != 0) { + changed = true; + } + identity_key_pair = store.identity_key_pair; + + // Validate signedPreKeyRecord + ID + if (signed_pre_key_id == -1 || !store.contains_signed_pre_key(signed_pre_key_id) || store.load_signed_pre_key(signed_pre_key_id).key_pair.public.compare(signed_pre_key) != 0) { + signed_pre_key_id = Random.int_range(1, int32.MAX); // TODO: No random, use ordered number + signed_pre_key_record = context.generate_signed_pre_key(identity_key_pair, signed_pre_key_id); + store.store_signed_pre_key(signed_pre_key_record); + changed = true; + } else { + signed_pre_key_record = store.load_signed_pre_key(signed_pre_key_id); + } + + // Validate PreKeys + Set pre_key_records = new HashSet(); + foreach (var entry in keys.entries) { + if (store.contains_pre_key(entry.key)) { + PreKeyRecord record = store.load_pre_key(entry.key); + if (record.key_pair.public.compare(entry.value) == 0) { + pre_key_records.add(record); + } + } + } + int new_keys = NUM_KEYS_TO_PUBLISH - pre_key_records.size; + if (new_keys > 0) { + int32 next_id = Random.int_range(1, int32.MAX); // TODO: No random, use ordered number + Set new_records = context.generate_pre_keys((uint)next_id, (uint)new_keys); + pre_key_records.add_all(new_records); + foreach (PreKeyRecord record in new_records) { + store.store_pre_key(record); + } + changed = true; + } + + if (changed) { + publish_bundles(stream, signed_pre_key_record, identity_key_pair, pre_key_records, (int32) store.local_registration_id); + } + } + } + + public static void publish_bundles(XmppStream stream, SignedPreKeyRecord signed_pre_key_record, IdentityKeyPair identity_key_pair, Set pre_key_records, int32 device_id) { + ECKeyPair tmp; + StanzaNode bundle = new StanzaNode.build("bundle", NS_URI) + .add_self_xmlns() + .put_node(new StanzaNode.build("signedPreKeyPublic", NS_URI) + .put_attribute("signedPreKeyId", signed_pre_key_record.id.to_string()) + .put_node(new StanzaNode.text(Base64.encode((tmp = signed_pre_key_record.key_pair).public.serialize())))) + .put_node(new StanzaNode.build("signedPreKeySignature", NS_URI) + .put_node(new StanzaNode.text(Base64.encode(signed_pre_key_record.signature)))) + .put_node(new StanzaNode.build("identityKey", NS_URI) + .put_node(new StanzaNode.text(Base64.encode(identity_key_pair.public.serialize())))); + StanzaNode prekeys = new StanzaNode.build("prekeys", NS_URI); + foreach (PreKeyRecord pre_key_record in pre_key_records) { + prekeys.put_node(new StanzaNode.build("preKeyPublic", NS_URI) + .put_attribute("preKeyId", pre_key_record.id.to_string()) + .put_node(new StanzaNode.text(Base64.encode(pre_key_record.key_pair.public.serialize())))); + } + bundle.put_node(prekeys); + + stream.get_module(Pubsub.Module.IDENTITY).publish(stream, null, @"$NODE_BUNDLES:$device_id", @"$NODE_BUNDLES:$device_id", "1", bundle); + } + + public override void detach(XmppStream stream) { + + } + + public static Module? get_module(XmppStream stream) { + return (Module?) stream.get_module(IDENTITY); + } + + public override string get_ns() { + return NS_URI; + } + + public override string get_id() { + return ID; + } +} + +public class MessageFlag : Message.MessageFlag { + public const string id = "axolotl"; + + public bool decrypted = false; + + public static MessageFlag? get_flag(Message.Stanza message) { + return (MessageFlag) message.get_flag(NS_URI, id); + } + + public override string get_ns() { + return NS_URI; + } + + public override string get_id() { + return id; + } +} + +internal class Bundle { + private StanzaNode? node; + + public Bundle(StanzaNode? node) { + this.node = node; + } + + public int32 signed_pre_key_id { owned get { + if (node == null) return -1; + string id = node.get_deep_attribute("signedPreKeyPublic", "signedPreKeyId"); + if (id == null) return -1; + return id.to_int(); + }} + + public ECPublicKey? signed_pre_key { owned get { + if (node == null) return null; + string? key = node.get_deep_string_content("signedPreKeyPublic"); + if (key == null) return null; + try { + return Module.context.decode_public_key(Base64.decode(key)); + } catch (Error e) { + return null; + } + }} + + public uint8[] signed_pre_key_signature { owned get { + if (node == null) return null; + string? sig = node.get_deep_string_content("signedPreKeySignature"); + if (sig == null) return null; + try { + return Base64.decode(sig); + } catch (Error e) { + return null; + } + }} + + public ECPublicKey? identity_key { owned get { + if (node == null) return null; + string? key = node.get_deep_string_content("identityKey"); + if (key == null) return null; + try { + return Module.context.decode_public_key(Base64.decode(key)); + } catch (Error e) { + return null; + } + }} + + public ArrayList pre_keys { owned get { + if (node == null || node.get_subnode("prekeys") == null) return null; + ArrayList list = new ArrayList(); + node.get_deep_subnodes("prekeys", "preKeyPublic") + .filter((node) => node.get_attribute("preKeyId") != null) + .map(PreKey.create) + .foreach((key) => list.add(key)); + return list; + }} + + internal class PreKey { + private StanzaNode node; + + public static PreKey create(owned StanzaNode node) { + return new PreKey(node); + } + + public PreKey(StanzaNode node) { + this.node = node; + } + + public int32 key_id { owned get { + return (node.get_attribute("preKeyId") ?? "-1").to_int(); + }} + + public ECPublicKey? key { owned get { + string? key = node.get_string_content(); + if (key == null) return null; + try { + return Module.context.decode_public_key(Base64.decode(key)); + } catch (Error e) { + return null; + } + }} + } +} + +public class EncryptStatus { + public bool encrypted { get; internal set; } + public int other_devices { get; internal set; } + public int other_success { get; internal set; } + public int other_lost { get; internal set; } + public int other_unknown { get; internal set; } + public int other_failure { get; internal set; } + public int own_devices { get; internal set; } + public int own_success { get; internal set; } + public int own_lost { get; internal set; } + public int own_unknown { get; internal set; } + public int own_failure { get; internal set; } +} + +} \ No newline at end of file diff --git a/plugins/omemo/src/plugin.vala b/plugins/omemo/src/plugin.vala new file mode 100644 index 00000000..a062640b --- /dev/null +++ b/plugins/omemo/src/plugin.vala @@ -0,0 +1,130 @@ +using Xmpp; + +namespace Dino.Omemo { + + public class EncryptionListEntry : Plugins.EncryptionListEntry, Object { + private Plugin plugin; + + public EncryptionListEntry(Plugin plugin) { + this.plugin = plugin; + } + + public Entities.Encryption encryption { get { + return Entities.Encryption.OMEMO; + }} + + public string name { get { + return "OMEMO"; + }} + + public bool can_encrypt(Entities.Conversation conversation) { + return Manager.get_instance(plugin.app.stream_interaction).con_encrypt(conversation); + } + } + + public class AccountSettingsEntry : Plugins.AccountSettingsEntry { + private Plugin plugin; + + public AccountSettingsEntry(Plugin plugin) { + this.plugin = plugin; + } + + public override string id { get { + return "omemo_identity_key"; + }} + + public override string name { get { + return "OMEMO"; + }} + + public override Plugins.AccountSettingsWidget get_widget() { + return new AccountSettingWidget(plugin); + } + } + + public class AccountSettingWidget : Plugins.AccountSettingsWidget, Gtk.Box { + private Plugin plugin; + private Gtk.Label fingerprint; + private Entities.Account account; + + public AccountSettingWidget(Plugin plugin) { + this.plugin = plugin; + + fingerprint = new Gtk.Label("..."); + fingerprint.xalign = 0; + Gtk.Border border = new Gtk.Button().get_style_context().get_padding(Gtk.StateFlags.NORMAL); + fingerprint.set_padding(border.left + 1, border.top + 1); + fingerprint.visible = true; + pack_start(fingerprint); + + Gtk.Button btn = new Gtk.Button(); + btn.image = new Gtk.Image.from_icon_name("view-list-symbolic", Gtk.IconSize.BUTTON); + btn.relief = Gtk.ReliefStyle.NONE; + btn.visible = true; + btn.valign = Gtk.Align.CENTER; + btn.clicked.connect(() => { activated(); }); + pack_start(btn, false); + } + + public void set_account(Entities.Account account) { + this.account = account; + try { + Qlite.Row? row = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id); + if (row == null) { + fingerprint.set_markup(@"Own fingerprint\nWill be generated on first connect"); + } else { + uint8[] arr = Base64.decode(row[plugin.db.identity.identity_key_public_base64]); + arr = arr[1:arr.length]; + string res = ""; + foreach (uint8 i in arr) { + string s = i.to_string("%x"); + if (s.length == 1) s = "0" + s; + res = res + s; + if ((res.length % 9) == 8) { + if (res.length == 35) { + res += "\n"; + } else { + res += " "; + } + } + } + fingerprint.set_markup(@"Own fingerprint\n$res"); + } + } catch (Qlite.DatabaseError e) { + fingerprint.set_markup(@"Own fingerprint\nDatabase error"); + } + } + + public void deactivate() { + } + } + + public class Plugin : Plugins.RootInterface, Object { + public Dino.Application app; + public Database db; + public EncryptionListEntry list_entry; + public AccountSettingsEntry settings_entry; + + public void registered(Dino.Application app) { + this.app = app; + this.db = new Database("omemo.db"); + this.list_entry = new EncryptionListEntry(this); + this.settings_entry = new AccountSettingsEntry(this); + app.plugin_registry.register_encryption_list_entry(list_entry); + app.plugin_registry.register_account_settings_entry(settings_entry); + app.stream_interaction.module_manager.initialize_account_modules.connect((account, list) => { + list.add(new Module()); + }); + Manager.start(app.stream_interaction, db); + } + + public void shutdown() { + // Nothing to do + } + } + +} + +public Type register_plugin(Module module) { + return typeof (Dino.Omemo.Plugin); +} -- cgit v1.2.3-54-g00ecf