aboutsummaryrefslogtreecommitdiff
path: root/plugins/omemo
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/omemo')
-rw-r--r--plugins/omemo/CMakeLists.txt41
-rw-r--r--plugins/omemo/src/database.vala80
-rw-r--r--plugins/omemo/src/manager.vala270
-rw-r--r--plugins/omemo/src/module.vala560
-rw-r--r--plugins/omemo/src/plugin.vala130
5 files changed, 1081 insertions, 0 deletions
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<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true };
+ public Column<int> account_id = new Column.Integer("account_id") { unique = true, not_null = true };
+ public Column<int> device_id = new Column.Integer("device_id") { not_null = true };
+ public Column<string> identity_key_private_base64 = new Column.Text("identity_key_private_base64") { not_null = true };
+ public Column<string> 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<int> identity_id = new Column.Integer("identity_id") { not_null = true };
+ public Column<int> signed_pre_key_id = new Column.Integer("signed_pre_key_id") { not_null = true };
+ public Column<string> 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<int> identity_id = new Column.Integer("identity_id") { not_null = true };
+ public Column<int> pre_key_id = new Column.Integer("pre_key_id") { not_null = true };
+ public Column<string> 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<int> identity_id = new Column.Integer("identity_id") { not_null = true };
+ public Column<string> address_name = new Column.Text("name") { not_null = true };
+ public Column<int> device_id = new Column.Integer("device_id") { not_null = true };
+ public Column<string> 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<Entities.Message> to_send_after_devicelist = new ArrayList<Entities.Message>();
+ private ArrayList<Entities.Message> to_send_after_session = new ArrayList<Entities.Message>();
+
+ 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<Entities.Message> 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<Entities.Message> 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<Module> IDENTITY = new ModuleIdentity<Module>(NS_URI, ID);
+
+ private Store store;
+ internal static Context context;
+ private bool device_list_loading = false;
+ private bool device_list_modified = false;
+ private Map<string, ArrayList<int32>> device_lists = new HashMap<string, ArrayList<int32>>();
+ private Map<string, ArrayList<int32>> ignored_devices = new HashMap<string, ArrayList<int32>>();
+
+ 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<int32>();
+ 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<int32>();
+ }
+ 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<Bundle.PreKey> 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<int, ECPublicKey> keys = new HashMap<int, ECPublicKey>();
+ 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<PreKeyRecord> pre_key_records = new HashSet<PreKeyRecord>();
+ 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<PreKeyRecord> 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<PreKeyRecord> 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<PreKey> pre_keys { owned get {
+ if (node == null || node.get_subnode("prekeys") == null) return null;
+ ArrayList<PreKey> list = new ArrayList<PreKey>();
+ node.get_deep_subnodes("prekeys", "preKeyPublic")
+ .filter((node) => node.get_attribute("preKeyId") != null)
+ .map<PreKey>(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\n<span font='8'>Will be generated on first connect</span>");
+ } 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<span font_family='monospace' font='8'>$res</span>");
+ }
+ } catch (Qlite.DatabaseError e) {
+ fingerprint.set_markup(@"Own fingerprint\n<span font='8'>Database error</span>");
+ }
+ }
+
+ 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);
+}