diff options
Diffstat (limited to 'plugins/omemo/src/logic')
-rw-r--r-- | plugins/omemo/src/logic/database.vala | 249 | ||||
-rw-r--r-- | plugins/omemo/src/logic/encrypt_state.vala | 24 | ||||
-rw-r--r-- | plugins/omemo/src/logic/manager.vala | 386 | ||||
-rw-r--r-- | plugins/omemo/src/logic/pre_key_store.vala | 45 | ||||
-rw-r--r-- | plugins/omemo/src/logic/session_store.vala | 49 | ||||
-rw-r--r-- | plugins/omemo/src/logic/signed_pre_key_store.vala | 45 | ||||
-rw-r--r-- | plugins/omemo/src/logic/trust_manager.vala | 346 |
7 files changed, 1144 insertions, 0 deletions
diff --git a/plugins/omemo/src/logic/database.vala b/plugins/omemo/src/logic/database.vala new file mode 100644 index 00000000..bce1d4e6 --- /dev/null +++ b/plugins/omemo/src/logic/database.vala @@ -0,0 +1,249 @@ +using Gee; +using Qlite; + +using Dino.Entities; + +namespace Dino.Plugins.Omemo { + +public class Database : Qlite.Database { + private const int VERSION = 4; + + public class IdentityMetaTable : Table { + public enum TrustLevel { + VERIFIED, + TRUSTED, + UNTRUSTED, + UNKNOWN; + + public string to_string() { + int val = this; + return val.to_string(); + } + } + + //Default to provide backwards compatability + public Column<int> identity_id = new Column.Integer("identity_id") { not_null = true, min_version = 2, default = "-1" }; + public Column<string> address_name = new Column.Text("address_name") { not_null = true }; + public Column<int> device_id = new Column.Integer("device_id") { not_null = true }; + public Column<string?> identity_key_public_base64 = new Column.Text("identity_key_public_base64"); + public Column<bool> trusted_identity = new Column.BoolInt("trusted_identity") { default = "0", max_version = 1 }; + public Column<int> trust_level = new Column.Integer("trust_level") { default = TrustLevel.UNKNOWN.to_string(), min_version = 2 }; + public Column<bool> now_active = new Column.BoolInt("now_active") { default = "1" }; + public Column<long> last_active = new Column.Long("last_active"); + + internal IdentityMetaTable(Database db) { + base(db, "identity_meta"); + init({identity_id, address_name, device_id, identity_key_public_base64, trusted_identity, trust_level, now_active, last_active}); + index("identity_meta_idx", {identity_id, address_name, device_id}, true); + index("identity_meta_list_idx", {identity_id, address_name}); + } + + public QueryBuilder with_address(int identity_id, string address_name) { + return select().with(this.identity_id, "=", identity_id).with(this.address_name, "=", address_name); + } + + public void insert_device_list(int32 identity_id, string address_name, ArrayList<int32> devices) { + update().with(this.identity_id, "=", identity_id).with(this.address_name, "=", address_name).set(now_active, false).perform(); + foreach (int32 device_id in devices) { + upsert() + .value(this.identity_id, identity_id, true) + .value(this.address_name, address_name, true) + .value(this.device_id, device_id, true) + .value(this.now_active, true) + .value(this.last_active, (long) new DateTime.now_utc().to_unix()) + .perform(); + } + } + + public int64 insert_device_bundle(int32 identity_id, string address_name, int device_id, Bundle bundle, TrustLevel trust) { + if (bundle == null || bundle.identity_key == null) return -1; + // Do not replace identity_key if it was known before, it should never change! + string identity_key = Base64.encode(bundle.identity_key.serialize()); + RowOption row = with_address(identity_id, address_name).with(this.device_id, "=", device_id).single().row(); + if (row.is_present() && row[identity_key_public_base64] != null && row[identity_key_public_base64] != identity_key) { + error("Tried to change the identity key for a known device id. Likely an attack."); + } + return upsert() + .value(this.identity_id, identity_id, true) + .value(this.address_name, address_name, true) + .value(this.device_id, device_id, true) + .value(this.identity_key_public_base64, identity_key) + .value(this.trust_level, trust).perform(); + } + + public QueryBuilder get_trusted_devices(int identity_id, string address_name) { + return this.with_address(identity_id, address_name) + .with(this.trust_level, "!=", TrustLevel.UNTRUSTED) + .with(this.now_active, "=", true); + } + + public QueryBuilder get_known_devices(int identity_id, string address_name) { + return this.with_address(identity_id, address_name) + .with(this.trust_level, "!=", TrustLevel.UNKNOWN) + .without_null(this.identity_key_public_base64); + } + + public QueryBuilder get_unknown_devices(int identity_id, string address_name) { + return this.with_address(identity_id, address_name) + .with_null(this.identity_key_public_base64); + } + + public QueryBuilder get_new_devices(int identity_id, string address_name) { + return this.with_address(identity_id, address_name) + .with(this.trust_level, "=", TrustLevel.UNKNOWN) + .without_null(this.identity_key_public_base64); + } + + public Row? get_device(int identity_id, string address_name, int device_id) { + return this.with_address(identity_id, address_name) + .with(this.device_id, "=", device_id).single().row().inner; + } + + public QueryBuilder get_with_device_id(int device_id) { + return select().with(this.device_id, "=", device_id); + } + } + + + public class TrustTable : Table { + public Column<int> identity_id = new Column.Integer("identity_id") { not_null = true }; + public Column<string> address_name = new Column.Text("address_name"); + public Column<bool> blind_trust = new Column.BoolInt("blind_trust") { default = "1" } ; + + internal TrustTable(Database db) { + base(db, "trust"); + init({identity_id, address_name, blind_trust}); + index("trust_idx", {identity_id, address_name}, true); + } + + public bool get_blind_trust(int32 identity_id, string address_name) { + return this.select().with(this.identity_id, "=", identity_id) + .with(this.address_name, "=", address_name) + .with(this.blind_trust, "=", true).count() > 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.NonNullText("identity_key_private_base64"); + public Column<string> identity_key_public_base64 = new Column.NonNullText("identity_key_public_base64"); + + internal IdentityTable(Database db) { + base(db, "identity"); + init({id, account_id, device_id, identity_key_private_base64, identity_key_public_base64}); + } + + public int get_id(int account_id) { + int id = -1; + Row? row = this.row_with(this.account_id, account_id).inner; + if (row != null) id = ((!)row)[this.id]; + return id; + } + } + + 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.NonNullText("record_base64"); + + internal SignedPreKeyTable(Database db) { + base(db, "signed_pre_key"); + init({identity_id, signed_pre_key_id, record_base64}); + unique({identity_id, signed_pre_key_id}); + index("signed_pre_key_idx", {identity_id, signed_pre_key_id}, true); + } + } + + 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.NonNullText("record_base64"); + + internal PreKeyTable(Database db) { + base(db, "pre_key"); + init({identity_id, pre_key_id, record_base64}); + unique({identity_id, pre_key_id}); + index("pre_key_idx", {identity_id, pre_key_id}, true); + } + } + + public class SessionTable : Table { + public Column<int> identity_id = new Column.Integer("identity_id") { not_null = true }; + public Column<string> address_name = new Column.NonNullText("name"); + public Column<int> device_id = new Column.Integer("device_id") { not_null = true }; + public Column<string> record_base64 = new Column.NonNullText("record_base64"); + + internal SessionTable(Database db) { + base(db, "session"); + init({identity_id, address_name, device_id, record_base64}); + unique({identity_id, address_name, device_id}); + index("session_idx", {identity_id, address_name, device_id}, true); + } + } + + public class ContentItemMetaTable : Table { + public Column<int> content_item_id = new Column.Integer("message_id") { primary_key = true }; + public Column<int> identity_id = new Column.Integer("identity_id") { not_null = true }; + public Column<string> address_name = new Column.Text("address_name") { not_null = true }; + public Column<int> device_id = new Column.Integer("device_id") { not_null = true }; + public Column<bool> trusted_when_received = new Column.BoolInt("trusted_when_received") { not_null = true, default = "1" }; + + internal ContentItemMetaTable(Database db) { + base(db, "content_item_meta"); + init({content_item_id, identity_id, address_name, device_id, trusted_when_received}); + index("content_item_meta_device_idx", {identity_id, device_id, address_name}); + } + + public RowOption with_content_item(ContentItem item) { + return row_with(content_item_id, item.id); + } + + public QueryBuilder with_device(int identity_id, string address_name, int device_id) { + return select() + .with(this.identity_id, "=", identity_id) + .with(this.address_name, "=", address_name) + .with(this.device_id, "=", device_id); + } + } + + public IdentityMetaTable identity_meta { get; private set; } + public TrustTable trust { get; private set; } + 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 ContentItemMetaTable content_item_meta { get; private set; } + + public Database(string fileName) { + base(fileName, VERSION); + identity_meta = new IdentityMetaTable(this); + trust = new TrustTable(this); + identity = new IdentityTable(this); + signed_pre_key = new SignedPreKeyTable(this); + pre_key = new PreKeyTable(this); + session = new SessionTable(this); + content_item_meta = new ContentItemMetaTable(this); + init({identity_meta, trust, identity, signed_pre_key, pre_key, session, content_item_meta}); + try { + exec("PRAGMA synchronous=0"); + } catch (Error e) { } + } + + public override void migrate(long oldVersion) { + if(oldVersion == 1) { + try { + exec("DROP INDEX identity_meta_idx"); + exec("DROP INDEX identity_meta_list_idx"); + exec("CREATE UNIQUE INDEX identity_meta_idx ON identity_meta (identity_id, address_name, device_id)"); + exec("CREATE INDEX identity_meta_list_idx ON identity_meta (identity_id, address_name)"); + } catch (Error e) { + stderr.printf("Failed to migrate OMEMO database\n"); + Process.exit(-1); + } + } + } +} + +} diff --git a/plugins/omemo/src/logic/encrypt_state.vala b/plugins/omemo/src/logic/encrypt_state.vala new file mode 100644 index 00000000..fd72faf4 --- /dev/null +++ b/plugins/omemo/src/logic/encrypt_state.vala @@ -0,0 +1,24 @@ +namespace Dino.Plugins.Omemo { + +public class EncryptState { + 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 other_waiting_lists { 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; } + public bool own_list { get; internal set; } + + public string to_string() { + return @"EncryptState (encrypted=$encrypted, other=(devices=$other_devices, success=$other_success, lost=$other_lost, unknown=$other_unknown, failure=$other_failure, waiting_lists=$other_waiting_lists, own=(devices=$own_devices, success=$own_success, lost=$own_lost, unknown=$own_unknown, failure=$own_failure, list=$own_list))"; + } +} + +} diff --git a/plugins/omemo/src/logic/manager.vala b/plugins/omemo/src/logic/manager.vala new file mode 100644 index 00000000..db64c3ee --- /dev/null +++ b/plugins/omemo/src/logic/manager.vala @@ -0,0 +1,386 @@ +using Dino.Entities; +using Signal; +using Qlite; +using Xmpp; +using Gee; + +namespace Dino.Plugins.Omemo { + +public class Manager : StreamInteractionModule, Object { + public static ModuleIdentity<Manager> IDENTITY = new ModuleIdentity<Manager>("omemo_manager"); + public string id { get { return IDENTITY.id; } } + + private StreamInteractor stream_interactor; + private Database db; + private TrustManager trust_manager; + private Map<Entities.Message, MessageState> message_states = new HashMap<Entities.Message, MessageState>(Entities.Message.hash_func, Entities.Message.equals_func); + + private class MessageState { + public Entities.Message msg { get; private set; } + public EncryptState last_try { get; private set; } + public int waiting_other_sessions { get; set; } + public int waiting_own_sessions { get; set; } + public bool waiting_own_devicelist { get; set; } + public int waiting_other_devicelists { get; set; } + public bool force_next_attempt { get; set; } + public bool will_send_now { get; private set; } + public bool active_send_attempt { get; set; } + + public MessageState(Entities.Message msg, EncryptState last_try) { + this.msg = msg; + this.last_try = last_try; + update_from_encrypt_status(last_try); + } + + public void update_from_encrypt_status(EncryptState new_try) { + this.last_try = new_try; + this.waiting_other_sessions = new_try.other_unknown; + this.waiting_own_sessions = new_try.own_unknown; + this.waiting_own_devicelist = !new_try.own_list; + this.waiting_other_devicelists = new_try.other_waiting_lists; + this.active_send_attempt = false; + will_send_now = false; + if (new_try.other_failure > 0 || (new_try.other_lost == new_try.other_devices && new_try.other_devices > 0)) { + msg.marked = Entities.Message.Marked.WONTSEND; + } else if (new_try.other_unknown > 0 || new_try.own_unknown > 0 || new_try.other_waiting_lists > 0 || !new_try.own_list || new_try.own_devices == 0) { + msg.marked = Entities.Message.Marked.UNSENT; + } else if (!new_try.encrypted) { + msg.marked = Entities.Message.Marked.WONTSEND; + } else { + will_send_now = true; + } + } + + public bool should_retry_now() { + return !waiting_own_devicelist && waiting_other_devicelists <= 0 && waiting_other_sessions <= 0 && waiting_own_sessions <= 0 && !active_send_attempt; + } + + public string to_string() { + return @"MessageState (waiting=(others=$waiting_other_sessions, own=$waiting_own_sessions, other_lists=$waiting_other_devicelists, own_list=$waiting_own_devicelist))"; + } + } + + private Manager(StreamInteractor stream_interactor, Database db, TrustManager trust_manager) { + this.stream_interactor = stream_interactor; + this.db = db; + this.trust_manager = trust_manager; + + stream_interactor.stream_negotiated.connect(on_stream_negotiated); + stream_interactor.account_added.connect(on_account_added); + stream_interactor.get_module(MessageProcessor.IDENTITY).pre_message_send.connect(on_pre_message_send); + stream_interactor.get_module(RosterManager.IDENTITY).mutual_subscription.connect(on_mutual_subscription); + } + + private Gee.List<Jid> get_occupants(Jid jid, Account account){ + Gee.List<Jid> occupants = new ArrayList<Jid>(Jid.equals_bare_func); + if(!stream_interactor.get_module(MucManager.IDENTITY).is_groupchat(jid, account)){ + occupants.add(jid); + } + Gee.List<Jid>? occupant_jids = stream_interactor.get_module(MucManager.IDENTITY).get_offline_members(jid, account); + if(occupant_jids == null) { + return occupants; + } + foreach (Jid occupant in occupant_jids) { + if(!occupant.equals(account.bare_jid)){ + occupants.add(occupant.bare_jid); + } + } + return occupants; + } + + private void on_pre_message_send(Entities.Message message, Xmpp.MessageStanza message_stanza, Conversation conversation) { + if (message.encryption == Encryption.OMEMO) { + XmppStream? stream = stream_interactor.get_stream(conversation.account); + if (stream == null) { + message.marked = Entities.Message.Marked.UNSENT; + return; + } + StreamModule? module_ = ((!)stream).get_module(StreamModule.IDENTITY); + if (module_ == null) { + message.marked = Entities.Message.Marked.UNSENT; + return; + } + StreamModule module = (!)module_; + + //Get a list of everyone for whom the message should be encrypted + Gee.List<Jid> recipients; + if (message_stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + recipients = get_occupants((!)message.to.bare_jid, conversation.account); + if (recipients.size == 0) { + message.marked = Entities.Message.Marked.WONTSEND; + return; + } + } else { + recipients = new ArrayList<Jid>(Jid.equals_bare_func); + recipients.add(message_stanza.to); + } + + //Attempt to encrypt the message + EncryptState enc_state = trust_manager.encrypt(message_stanza, conversation.account.bare_jid, recipients, stream, conversation.account); + MessageState state; + lock (message_states) { + if (message_states.has_key(message)) { + state = message_states.get(message); + state.update_from_encrypt_status(enc_state); + } else { + state = new MessageState(message, enc_state); + message_states[message] = state; + } + if (state.will_send_now) { + message_states.unset(message); + } + } + + //Encryption failed - need to fetch more information + if (!state.will_send_now) { + if (message.marked == Entities.Message.Marked.WONTSEND) { + debug("message was not sent: %s", state.to_string()); + message_states.unset(message); + } else { + debug("message will be delayed: %s", state.to_string()); + + if (state.waiting_own_sessions > 0) { + module.fetch_bundles((!)stream, conversation.account.bare_jid, trust_manager.get_trusted_devices(conversation.account, conversation.account.bare_jid)); + } + if (state.waiting_other_sessions > 0 && message.counterpart != null) { + foreach(Jid jid in get_occupants(((!)message.counterpart).bare_jid, conversation.account)) { + module.fetch_bundles((!)stream, jid, trust_manager.get_trusted_devices(conversation.account, jid)); + } + } + if (state.waiting_other_devicelists > 0 && message.counterpart != null) { + foreach(Jid jid in get_occupants(((!)message.counterpart).bare_jid, conversation.account)) { + module.request_user_devicelist((!)stream, jid); + } + } + } + } + } + } + + private void on_mutual_subscription(Account account, Jid jid) { + XmppStream? stream = stream_interactor.get_stream(account); + if(stream == null) return; + + stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).request_user_devicelist((!)stream, jid); + } + + private void on_account_added(Account account) { + stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).store_created.connect((store) => on_store_created.begin(account, store)); + stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).device_list_loaded.connect((jid, devices) => on_device_list_loaded(account, jid, devices)); + stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).bundle_fetched.connect((jid, device_id, bundle) => on_bundle_fetched(account, jid, device_id, bundle)); + } + + private void on_stream_negotiated(Account account, XmppStream stream) { + stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).request_user_devicelist(stream, account.bare_jid); + } + + private void on_device_list_loaded(Account account, Jid jid, ArrayList<int32> device_list) { + debug("received device list for %s from %s", account.bare_jid.to_string(), jid.to_string()); + + XmppStream? stream = stream_interactor.get_stream(account); + if (stream == null) { + return; + } + StreamModule? module = ((!)stream).get_module(StreamModule.IDENTITY); + if (module == null) { + return; + } + + int identity_id = db.identity.get_id(account.id); + if (identity_id < 0) return; + + //Update meta database + db.identity_meta.insert_device_list(identity_id, jid.bare_jid.to_string(), device_list); + + //Fetch the bundle for each new device + int inc = 0; + foreach (Row row in db.identity_meta.get_unknown_devices(identity_id, jid.bare_jid.to_string())) { + module.fetch_bundle(stream, Jid.parse(row[db.identity_meta.address_name]), row[db.identity_meta.device_id]); + inc++; + } + if (inc > 0) { + debug("new bundles %i/%i for %s", inc, device_list.size, jid.to_string()); + } + + //Create an entry for the jid in the account table if one does not exist already + if (db.trust.select().with(db.trust.identity_id, "=", identity_id).with(db.trust.address_name, "=", jid.bare_jid.to_string()).count() == 0) { + db.trust.insert().value(db.trust.identity_id, identity_id).value(db.trust.address_name, jid.bare_jid.to_string()).value(db.trust.blind_trust, true).perform(); + } + + //Get all messages that needed the devicelist and determine if we can now send them + HashSet<Entities.Message> send_now = new HashSet<Entities.Message>(); + lock (message_states) { + foreach (Entities.Message msg in message_states.keys) { + if (!msg.account.equals(account)) continue; + Gee.List<Jid> occupants = get_occupants(msg.counterpart.bare_jid, account); + MessageState state = message_states[msg]; + if (account.bare_jid.equals(jid)) { + state.waiting_own_devicelist = false; + } else if (msg.counterpart != null && occupants.contains(jid)) { + state.waiting_other_devicelists--; + } + if (state.should_retry_now()) { + send_now.add(msg); + state.active_send_attempt = true; + } + } + } + foreach (Entities.Message msg in send_now) { + if (msg.counterpart == null) continue; + Entities.Conversation? conv = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(((!)msg.counterpart), account); + if (conv == null) continue; + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(msg, (!)conv, true); + } + + } + + public void on_bundle_fetched(Account account, Jid jid, int32 device_id, Bundle bundle) { + int identity_id = db.identity.get_id(account.id); + if (identity_id < 0) return; + + bool blind_trust = db.trust.get_blind_trust(identity_id, jid.bare_jid.to_string()); + + //If we don't blindly trust new devices and we haven't seen this key before then don't trust it + bool untrust = !(blind_trust || db.identity_meta.with_address(identity_id, jid.bare_jid.to_string()) + .with(db.identity_meta.device_id, "=", device_id) + .with(db.identity_meta.identity_key_public_base64, "=", Base64.encode(bundle.identity_key.serialize())) + .single().row().is_present()); + + //Get trust information from the database if the device id is known + Row device = db.identity_meta.get_device(identity_id, jid.bare_jid.to_string(), device_id); + Database.IdentityMetaTable.TrustLevel trusted = Database.IdentityMetaTable.TrustLevel.UNKNOWN; + if (device != null) { + trusted = (Database.IdentityMetaTable.TrustLevel) device[db.identity_meta.trust_level]; + } + + if(untrust) { + trusted = Database.IdentityMetaTable.TrustLevel.UNKNOWN; + } else if (blind_trust && trusted == Database.IdentityMetaTable.TrustLevel.UNKNOWN) { + trusted = Database.IdentityMetaTable.TrustLevel.TRUSTED; + } + + //Update the database with the appropriate trust information + db.identity_meta.insert_device_bundle(identity_id, jid.bare_jid.to_string(), device_id, bundle, trusted); + + XmppStream? stream = stream_interactor.get_stream(account); + if(stream == null) return; + StreamModule? module = ((!)stream).get_module(StreamModule.IDENTITY); + if(module == null) return; + + //Get all messages waiting on the bundle and determine if they can now be sent + HashSet<Entities.Message> send_now = new HashSet<Entities.Message>(); + lock (message_states) { + foreach (Entities.Message msg in message_states.keys) { + + bool session_created = true; + if (!msg.account.equals(account)) continue; + Gee.List<Jid> occupants = get_occupants(msg.counterpart.bare_jid, account); + + MessageState state = message_states[msg]; + + if (trusted == Database.IdentityMetaTable.TrustLevel.TRUSTED || trusted == Database.IdentityMetaTable.TrustLevel.VERIFIED) { + if(account.bare_jid.equals(jid) || (msg.counterpart != null && (msg.counterpart.equals_bare(jid) || occupants.contains(jid)))) { + session_created = module.start_session(stream, jid, device_id, bundle); + } + } + if (account.bare_jid.equals(jid) && session_created) { + state.waiting_own_sessions--; + } else if (msg.counterpart != null && (msg.counterpart.equals_bare(jid) || occupants.contains(jid)) && session_created) { + state.waiting_other_sessions--; + } + if (state.should_retry_now()){ + send_now.add(msg); + state.active_send_attempt = true; + } + } + } + foreach (Entities.Message msg in send_now) { + if (msg.counterpart == null) continue; + Entities.Conversation? conv = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation((!)msg.counterpart, account); + if (conv == null) continue; + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(msg, (!)conv, true); + } + } + + private async void on_store_created(Account account, Store store) { + // If the account is not yet persisted, wait for that and then continue - without identity.account_id the entry isn't worth much. + if (account.id == -1) { + account.notify["id"].connect(() => on_store_created.callback()); + yield; + } + Qlite.Row? row = db.identity.row_with(db.identity.account_id, account.id).inner; + int identity_id = -1; + bool publish_identity = false; + + if (row == null) { + // OMEMO not yet initialized, starting with empty base + publish_identity = true; + try { + store.identity_key_store.local_registration_id = Random.int_range(1, int32.MAX); + + Signal.ECKeyPair key_pair = Plugin.get_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(); + + 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 { + warning("store for %s is not persisted!", account.bare_jid.to_string()); + } + + // Generated new device ID, ensure this gets added to the devicelist + if (publish_identity) { + XmppStream? stream = stream_interactor.get_stream(account); + if (stream == null) return; + StreamModule? module = ((!)stream).get_module(StreamModule.IDENTITY); + if(module == null) return; + module.request_user_devicelist(stream, account.bare_jid); + } + } + + + public bool can_encrypt(Entities.Conversation conversation) { + XmppStream? stream = stream_interactor.get_stream(conversation.account); + if (stream == null) return false; + if (stream_interactor.get_module(MucManager.IDENTITY).is_groupchat(conversation.counterpart, conversation.account)){ + Xep.Muc.Flag? flag = stream.get_flag(Xep.Muc.Flag.IDENTITY); + if (flag == null) return false; + if (flag.has_room_feature(conversation.counterpart, Xep.Muc.Feature.NON_ANONYMOUS) && flag.has_room_feature(conversation.counterpart, Xep.Muc.Feature.MEMBERS_ONLY)) { + foreach(Jid jid in stream_interactor.get_module(MucManager.IDENTITY).get_offline_members(conversation.counterpart, conversation.account)) { + if (!trust_manager.is_known_address(conversation.account, jid.bare_jid)) { + return false; + } + } + return true; + } else { + return false; + } + } + return trust_manager.is_known_address(conversation.account, conversation.counterpart.bare_jid); + } + + public static void start(StreamInteractor stream_interactor, Database db, TrustManager trust_manager) { + Manager m = new Manager(stream_interactor, db, trust_manager); + stream_interactor.add_module(m); + } +} + +} diff --git a/plugins/omemo/src/logic/pre_key_store.vala b/plugins/omemo/src/logic/pre_key_store.vala new file mode 100644 index 00000000..716fd32f --- /dev/null +++ b/plugins/omemo/src/logic/pre_key_store.vala @@ -0,0 +1,45 @@ +using Signal; +using Qlite; + +namespace Dino.Plugins.Omemo { + +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() { + try { + 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])); + } + } catch (Error e) { + warning("Error while initializing pre key store: %s", e.message); + } + + 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(); + } +} + +} diff --git a/plugins/omemo/src/logic/session_store.vala b/plugins/omemo/src/logic/session_store.vala new file mode 100644 index 00000000..654591d1 --- /dev/null +++ b/plugins/omemo/src/logic/session_store.vala @@ -0,0 +1,49 @@ +using Signal; +using Qlite; + +namespace Dino.Plugins.Omemo { + +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() { + try { + foreach (Row row in db.session.select().with(db.session.identity_id, "=", identity_id)) { + Address addr = new Address(row[db.session.address_name], row[db.session.device_id]); + store_session(addr, Base64.decode(row[db.session.record_base64])); + addr.device_id = 0; + } + } catch (Error e) { + print("Error while initializing session store: %s", e.message); + } + + 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(); + } +} + +} diff --git a/plugins/omemo/src/logic/signed_pre_key_store.vala b/plugins/omemo/src/logic/signed_pre_key_store.vala new file mode 100644 index 00000000..8ff54a93 --- /dev/null +++ b/plugins/omemo/src/logic/signed_pre_key_store.vala @@ -0,0 +1,45 @@ +using Qlite; +using Signal; + +namespace Dino.Plugins.Omemo { + +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() { + try { + 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])); + } + } catch (Error e) { + print("Error while initializing signed pre key store: %s", e.message); + } + + 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(); + } +} + +} diff --git a/plugins/omemo/src/logic/trust_manager.vala b/plugins/omemo/src/logic/trust_manager.vala new file mode 100644 index 00000000..d57adc35 --- /dev/null +++ b/plugins/omemo/src/logic/trust_manager.vala @@ -0,0 +1,346 @@ +using Dino.Entities; +using Gee; +using Xmpp; +using Signal; +using Qlite; + +namespace Dino.Plugins.Omemo { + +public class TrustManager { + + private StreamInteractor stream_interactor; + private Database db; + private DecryptMessageListener decrypt_message_listener; + private TagMessageListener tag_message_listener; + + private HashMap<Message, int> message_device_id_map = new HashMap<Message, int>(Message.hash_func, Message.equals_func); + + public TrustManager(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + + decrypt_message_listener = new DecryptMessageListener(stream_interactor, db, message_device_id_map); + tag_message_listener = new TagMessageListener(stream_interactor, db, message_device_id_map); + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(decrypt_message_listener); + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(tag_message_listener); + } + + public void set_blind_trust(Account account, Jid jid, bool blind_trust) { + int identity_id = db.identity.get_id(account.id); + if (identity_id < 0) return; + db.trust.update() + .with(db.trust.identity_id, "=", identity_id) + .with(db.trust.address_name, "=", jid.bare_jid.to_string()) + .set(db.trust.blind_trust, blind_trust).perform(); + } + + public void set_device_trust(Account account, Jid jid, int device_id, Database.IdentityMetaTable.TrustLevel trust_level) { + int identity_id = db.identity.get_id(account.id); + db.identity_meta.update() + .with(db.identity_meta.identity_id, "=", identity_id) + .with(db.identity_meta.address_name, "=", jid.bare_jid.to_string()) + .with(db.identity_meta.device_id, "=", device_id) + .set(db.identity_meta.trust_level, trust_level).perform(); + string selection = null; + string[] selection_args = {}; + var app_db = Application.get_default().db; + foreach (Row row in db.content_item_meta.with_device(identity_id, jid.bare_jid.to_string(), device_id).with(db.content_item_meta.trusted_when_received, "=", false)) { + if (selection == null) { + selection = @"$(app_db.content_item.id) = ?"; + } else { + selection += @" OR $(app_db.content_item.id) = ?"; + } + selection_args += row[db.content_item_meta.content_item_id].to_string(); + } + if (selection != null) { + app_db.content_item.update() + .set(app_db.content_item.hide, trust_level == Database.IdentityMetaTable.TrustLevel.UNTRUSTED || trust_level == Database.IdentityMetaTable.TrustLevel.UNKNOWN) + .where(selection, selection_args) + .perform(); + } + } + + private StanzaNode create_encrypted_key(uint8[] key, Address address, Store store) 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 EncryptState encrypt(MessageStanza message, Jid self_jid, Gee.List<Jid> recipients, XmppStream stream, Account account) { + EncryptState status = new EncryptState(); + if (!Plugin.ensure_context()) return status; + if (message.to == null) return status; + + StreamModule module = stream.get_module(StreamModule.IDENTITY); + + try { + //Check we have the bundles and device lists needed to send the message + if (!is_known_address(account, self_jid)) return status; + status.own_list = true; + status.own_devices = get_trusted_devices(account, self_jid).size; + status.other_waiting_lists = 0; + status.other_devices = 0; + foreach (Jid recipient in recipients) { + if (!is_known_address(account, recipient)) { + status.other_waiting_lists++; + } + if (status.other_waiting_lists > 0) return status; + status.other_devices += get_trusted_devices(account, recipient).size; + } + if (status.own_devices == 0 || status.other_devices == 0) return status; + + //Create a key and use it to encrypt the message + uint8[] key = new uint8[16]; + Plugin.get_context().randomize(key); + uint8[] iv = new uint8[16]; + Plugin.get_context().randomize(iv); + + uint8[] aes_encrypt_result = aes_encrypt(Cipher.AES_GCM_NOPADDING, key, iv, message.body.data); + uint8[] ciphertext = aes_encrypt_result[0:aes_encrypt_result.length-16]; + uint8[] tag = aes_encrypt_result[aes_encrypt_result.length-16:aes_encrypt_result.length]; + uint8[] keytag = new uint8[key.length + tag.length]; + Memory.copy(keytag, key, key.length); + Memory.copy((uint8*)keytag + key.length, tag, tag.length); + + StanzaNode header; + StanzaNode encrypted = new StanzaNode.build("encrypted", NS_URI).add_self_xmlns() + .put_node(header = new StanzaNode.build("header", NS_URI) + .put_attribute("sid", module.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)))); + + //Encrypt the key for each recipient's device individually + Address address = new Address(message.to.bare_jid.to_string(), 0); + foreach (Jid recipient in recipients) { + foreach(int32 device_id in get_trusted_devices(account, recipient)) { + if (module.is_ignored_device(recipient, device_id)) { + status.other_lost++; + continue; + } + try { + address.name = recipient.bare_jid.to_string(); + address.device_id = (int) device_id; + StanzaNode key_node = create_encrypted_key(keytag, address, module.store); + 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_jid.bare_jid.to_string(); + foreach(int32 device_id in get_trusted_devices(account, self_jid)) { + if (module.is_ignored_device(self_jid, device_id)) { + status.own_lost++; + continue; + } + if (device_id != module.store.local_registration_id) { + address.device_id = (int) device_id; + try { + StanzaNode key_node = create_encrypted_key(keytag, address, module.store); + 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); + Xep.ExplicitEncryption.add_encryption_tag_to_message(message, NS_URI, "OMEMO"); + message.body = "[This message is OMEMO encrypted]"; + status.encrypted = true; + } catch (Error e) { + warning(@"Signal error while encrypting message: $(e.message)\n"); + } + return status; + } + + public bool is_known_address(Account account, Jid jid) { + int identity_id = db.identity.get_id(account.id); + if (identity_id < 0) return false; + return db.identity_meta.with_address(identity_id, jid.to_string()).count() > 0; + } + + public Gee.List<int32> get_trusted_devices(Account account, Jid jid) { + Gee.List<int32> devices = new ArrayList<int32>(); + int identity_id = db.identity.get_id(account.id); + if (identity_id < 0) return devices; + foreach (Row device in db.identity_meta.get_trusted_devices(identity_id, jid.bare_jid.to_string())) { + if(device[db.identity_meta.trust_level] != Database.IdentityMetaTable.TrustLevel.UNKNOWN || device[db.identity_meta.identity_key_public_base64] == null) + devices.add(device[db.identity_meta.device_id]); + } + return devices; + } + + private class TagMessageListener : MessageListener { + public string[] after_actions_const = new string[]{ "STORE" }; + public override string action_group { get { return "DECRYPT_TAG"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private StreamInteractor stream_interactor; + private Database db; + private HashMap<Message, int> message_device_id_map; + + public TagMessageListener(StreamInteractor stream_interactor, Database db, HashMap<Message, int> message_device_id_map) { + this.stream_interactor = stream_interactor; + this.db = db; + this.message_device_id_map = message_device_id_map; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + int device_id = 0; + if (message_device_id_map.has_key(message)) { + device_id = message_device_id_map[message]; + message_device_id_map.unset(message); + } + + // TODO: Handling of files + + ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item(conversation, 1, message.id); + + if (content_item != null && device_id != 0) { + Jid jid = content_item.jid; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + jid = message.real_jid; + } + + int identity_id = db.identity.get_id(conversation.account.id); + Database.IdentityMetaTable.TrustLevel trust_level = (Database.IdentityMetaTable.TrustLevel) db.identity_meta.get_device(identity_id, jid.bare_jid.to_string(), device_id)[db.identity_meta.trust_level]; + if (trust_level == Database.IdentityMetaTable.TrustLevel.UNTRUSTED || trust_level == Database.IdentityMetaTable.TrustLevel.UNKNOWN) { + stream_interactor.get_module(ContentItemStore.IDENTITY).set_item_hide(content_item, true); + } + + db.content_item_meta.insert() + .value(db.content_item_meta.content_item_id, content_item.id) + .value(db.content_item_meta.identity_id, identity_id) + .value(db.content_item_meta.address_name, jid.bare_jid.to_string()) + .value(db.content_item_meta.device_id, device_id) + .value(db.content_item_meta.trusted_when_received, trust_level != Database.IdentityMetaTable.TrustLevel.UNTRUSTED) + .perform(); + } + return false; + } + } + + private class DecryptMessageListener : MessageListener { + public string[] after_actions_const = new string[]{ }; + public override string action_group { get { return "DECRYPT"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private StreamInteractor stream_interactor; + private Database db; + private HashMap<Message, int> message_device_id_map; + + public DecryptMessageListener(StreamInteractor stream_interactor, Database db, HashMap<Message, int> message_device_id_map) { + this.stream_interactor = stream_interactor; + this.db = db; + this.message_device_id_map = message_device_id_map; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + Store store = stream_interactor.module_manager.get_module(conversation.account, StreamModule.IDENTITY).store; + + StanzaNode? _encrypted = stanza.stanza.get_subnode("encrypted", NS_URI); + if (_encrypted == null || MessageFlag.get_flag(stanza) != null || stanza.from == null) return false; + StanzaNode encrypted = (!)_encrypted; + if (message.body == null && Xep.ExplicitEncryption.get_encryption_tag(stanza) == NS_URI) { + message.body = "[This message is OMEMO encrypted]"; // TODO temporary + }; + if (!Plugin.ensure_context()) return false; + MessageFlag flag = new MessageFlag(); + stanza.add_flag(flag); + StanzaNode? _header = encrypted.get_subnode("header"); + if (_header == null) return false; + StanzaNode header = (!)_header; + int sid = header.get_attribute_int("sid"); + if (sid <= 0) return false; + foreach (StanzaNode key_node in header.get_subnodes("key")) { + if (key_node.get_attribute_int("rid") == store.local_registration_id) { + try { + string? payload = encrypted.get_deep_string_content("payload"); + string? iv_node = header.get_deep_string_content("iv"); + string? key_node_content = key_node.get_string_content(); + if (payload == null || iv_node == null || key_node_content == null) continue; + uint8[] key; + uint8[] ciphertext = Base64.decode((!)payload); + uint8[] iv = Base64.decode((!)iv_node); + Gee.List<Jid> possible_jids = new ArrayList<Jid>(); + if (conversation.type_ == Conversation.Type.CHAT) { + possible_jids.add(stanza.from); + } else { + Jid? real_jid = message.real_jid; + if (real_jid != null) { + possible_jids.add(real_jid); + } else { + foreach (Row row in db.identity_meta.get_with_device_id(sid)) { + possible_jids.add(new Jid(row[db.identity_meta.address_name])); + } + } + } + + foreach (Jid possible_jid in possible_jids) { + try { + Address address = new Address(possible_jid.bare_jid.to_string(), header.get_attribute_int("sid")); + if (key_node.get_attribute_bool("prekey")) { + PreKeySignalMessage msg = Plugin.get_context().deserialize_pre_key_signal_message(Base64.decode((!)key_node_content)); + SessionCipher cipher = store.create_session_cipher(address); + key = cipher.decrypt_pre_key_signal_message(msg); + } else { + SignalMessage msg = Plugin.get_context().deserialize_signal_message(Base64.decode((!)key_node_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.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)); + message_device_id_map[message] = address.device_id; + message.encryption = Encryption.OMEMO; + flag.decrypted = true; + } catch (Error e) { + continue; + } + + // If we figured out which real jid a message comes from due to + if (conversation.type_ == Conversation.Type.GROUPCHAT && message.real_jid == null) { + message.real_jid = possible_jid; + } + break; + } + } catch (Error e) { + warning(@"Signal error while decrypting message: $(e.message)\n"); + } + } + } + return false; + } + + 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; + } + } +} + +} |