omemo: store and display identity keys of all devices

This commit is contained in:
Marvin W 2017-05-13 17:48:13 +02:00
parent ad033beea8
commit 9840774a87
No known key found for this signature in database
GPG key ID: 072E9235DB996F2A
11 changed files with 424 additions and 27 deletions

View file

@ -11,11 +11,27 @@ find_packages(OMEMO_PACKAGES REQUIRED
GTK3
)
set(RESOURCE_LIST
account_settings_dialog.ui
)
compile_gresources(
OMEMO_GRESOURCES_TARGET
OMEMO_GRESOURCES_XML
TARGET ${CMAKE_CURRENT_BINARY_DIR}/resources/resources.c
TYPE EMBED_C
RESOURCES ${RESOURCE_LIST}
PREFIX /im/dino/omemo
SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data
)
vala_precompile(OMEMO_VALA_C
SOURCES
src/account_settings_dialog.vala
src/account_settings_entry.vala
src/account_settings_widget.vala
src/bundle.vala
src/contact_details_provider.vala
src/database.vala
src/encrypt_state.vala
src/encryption_list_entry.vala
@ -27,6 +43,7 @@ SOURCES
src/session_store.vala
src/signed_pre_key_store.vala
src/stream_module.vala
src/util.vala
CUSTOM_VAPIS
${CMAKE_BINARY_DIR}/exports/signal-protocol.vapi
${CMAKE_BINARY_DIR}/exports/xmpp-vala.vapi
@ -34,10 +51,12 @@ CUSTOM_VAPIS
${CMAKE_BINARY_DIR}/exports/dino.vapi
PACKAGES
${OMEMO_PACKAGES}
GRESOURCES
${OMEMO_GRESOURCES_XML}
)
add_definitions(${VALA_CFLAGS} -DGETTEXT_PACKAGE=\"${GETTEXT_PACKAGE}\" -DLOCALE_INSTALL_DIR=\"${LOCALE_INSTALL_DIR}\")
add_library(omemo SHARED ${OMEMO_VALA_C})
add_library(omemo SHARED ${OMEMO_VALA_C} ${OMEMO_GRESOURCES_TARGET})
add_dependencies(omemo ${GETTEXT_PACKAGE}-translations)
target_link_libraries(omemo libdino signal-protocol-vala ${OMEMO_PACKAGES})
set_target_properties(omemo PROPERTIES PREFIX "")

View file

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="DinoPluginsOmemoAccountSettingsDialog">
<property name="modal">True</property>
<property name="title" translatable="yes">OMEMO Keys</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="visible">True</property>
<property name="margin-left">40</property>
<property name="margin-right">40</property>
<child>
<object class="GtkBox">
<property name="margin-top">12</property>
<property name="orientation">horizontal</property>
<property name="visible">True</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="label" translatable="yes">Own fingerprint</property>
<property name="xalign">0</property>
<property name="yalign">1</property>
<property name="hexpand">True</property>
<property name="margin-bottom">2</property>
<attributes>
<attribute name="weight" value="PANGO_WEIGHT_BOLD"/>
</attributes>
</object>
</child>
<child>
<object class="GtkButton" id="copy_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<style>
<class name="flat"/>
</style>
<signal name="clicked" handler="copy_button_clicked"/>
<child>
<object class="GtkImage">
<property name="icon-name">edit-copy-symbolic</property>
<property name="icon-size">1</property>
<property name="visible">True</property>
</object>
</child>
</object>
</child>
<!--<child>
<object class="GtkButton" id="qr_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="sensitive">False</property>
<style>
<class name="flat"/>
</style>
<child>
<object class="GtkImage">
<property name="icon-name">camera-photo-symbolic</property>
<property name="icon-size">1</property>
<property name="visible">True</property>
</object>
</child>
</object>
</child>-->
</object>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<child>
<object class="GtkListBox">
<property name="visible">True</property>
<property name="selection-mode">none</property>
<child>
<object class="GtkLabel" id="own_fingerprint">
<property name="visible">True</property>
<property name="margin">8</property>
<property name="label">...</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="margin-top">12</property>
<property name="visible">True</property>
<property name="xalign">0</property>
<property name="label" translatable="yes">Other devices</property>
<property name="margin-bottom">2</property>
<attributes>
<attribute name="weight" value="PANGO_WEIGHT_BOLD"/>
</attributes>
</object>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="margin-bottom">18</property>
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar_policy">never</property>
<property name="vscrollbar_policy">never</property>
<property name="visible">True</property>
<child>
<object class="GtkListBox" id="other_list">
<property name="visible">True</property>
<property name="selection-mode">none</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="margin">8</property>
<property name="label" translatable="yes">- None -</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</interface>

View file

@ -0,0 +1,53 @@
using Gtk;
using Qlite;
using Dino.Entities;
namespace Dino.Plugins.Omemo {
[GtkTemplate (ui = "/im/dino/omemo/account_settings_dialog.ui")]
public class AccountSettingsDialog : Gtk.Dialog {
private Plugin plugin;
private Account account;
private string fingerprint;
[GtkChild] private Label own_fingerprint;
[GtkChild] private ListBox other_list;
public AccountSettingsDialog(Plugin plugin, Account account) {
Object(use_header_bar : 1);
this.plugin = plugin;
string own_b64 = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id)[plugin.db.identity.identity_key_public_base64];
fingerprint = fingerprint_from_base64(own_b64);
own_fingerprint.set_markup(fingerprint_markup(fingerprint));
int own_id = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id)[plugin.db.identity.device_id];
int i = 0;
foreach (Row row in plugin.db.identity_meta.with_address(account.bare_jid.to_string())) {
if (row[plugin.db.identity_meta.device_id] == own_id) continue;
if (i == 0) {
other_list.foreach((widget) => { other_list.remove(widget); });
}
string? other_b64 = row[plugin.db.identity_meta.identity_key_public_base64];
Label lbl = new Label(other_b64 != null ? fingerprint_markup(fingerprint_from_base64(other_b64)) : _("Unknown device (0x%xd)").printf(row[plugin.db.identity_meta.device_id])) { use_markup = true, visible = true, margin = 8, selectable=true };
if (row[plugin.db.identity_meta.now_active] && other_b64 != null) {
other_list.insert(lbl, 0);
} else {
lbl.sensitive = false;
other_list.insert(lbl, i);
}
i++;
}
}
[GtkCallback]
public void copy_button_clicked() {
Clipboard.get_default(get_display()).set_text(fingerprint, fingerprint.length);
}
}
}

View file

@ -7,6 +7,7 @@ public class AccountSettingWidget : Plugins.AccountSettingsWidget, Box {
private Plugin plugin;
private Label fingerprint;
private Account account;
private Button btn;
public AccountSettingWidget(Plugin plugin) {
this.plugin = plugin;
@ -18,38 +19,31 @@ public class AccountSettingWidget : Plugins.AccountSettingsWidget, Box {
fingerprint.visible = true;
pack_start(fingerprint);
Button btn = new Button();
btn = new Button();
btn.image = new Image.from_icon_name("view-list-symbolic", IconSize.BUTTON);
btn.relief = ReliefStyle.NONE;
btn.visible = true;
btn.visible = false;
btn.valign = Align.CENTER;
btn.clicked.connect(() => { activated(); });
btn.clicked.connect(() => {
activated();
AccountSettingsDialog dialog = new AccountSettingsDialog(plugin, account);
dialog.set_transient_for((Window) get_toplevel());
dialog.present();
});
pack_start(btn, false);
}
public void set_account(Account account) {
this.account = account;
btn.visible = false;
try {
Qlite.Row? row = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id).inner;
if (row == null) {
fingerprint.set_markup("%s\n<span font='8'>%s</span>".printf(_("Own fingerprint"), _("Will 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 += " ";
}
}
}
string res = fingerprint_markup(fingerprint_from_base64(((!)row)[plugin.db.identity.identity_key_public_base64]));
fingerprint.set_markup("%s\n<span font_family='monospace' font='8'>%s</span>".printf(_("Own fingerprint"), res));
btn.visible = true;
}
} catch (Qlite.DatabaseError e) {
fingerprint.set_markup("%s\n<span font='8'>%s</span>".printf(_("Own fingerprint"), _("Database error")));

View file

@ -0,0 +1,37 @@
using Gtk;
using Qlite;
using Dino.Entities;
namespace Dino.Plugins.Omemo {
public class ContactDetailsProvider : Plugins.ContactDetailsProvider, Object {
public string id { get { return "omemo_info"; } }
private Plugin plugin;
public ContactDetailsProvider(Plugin plugin) {
this.plugin = plugin;
}
public void populate(Conversation conversation, Plugins.ContactDetails contact_details, WidgetType type) {
if (conversation.type_ == Conversation.Type.CHAT && type == WidgetType.GTK) {
string res = "";
int i = 0;
foreach (Row row in plugin.db.identity_meta.with_address(conversation.counterpart.to_string())) {
if (row[plugin.db.identity_meta.identity_key_public_base64] != null) {
if (i != 0) {
res += "\n\n";
}
res += fingerprint_markup(fingerprint_from_base64(row[plugin.db.identity_meta.identity_key_public_base64]));
i++;
}
}
if (i > 0) {
Label label = new Label(res) { use_markup=true, justify=Justification.RIGHT, selectable=true, visible=true };
contact_details.add(_("Encryption"), _("OMEMO"), n("%d OMEMO device", "%d OMEMO devices", i).printf(i), label);
}
}
}
}
}

View file

@ -6,7 +6,48 @@ using Dino.Entities;
namespace Dino.Plugins.Omemo {
public class Database : Qlite.Database {
private const int VERSION = 0;
private const int VERSION = 1;
public class IdentityMetaTable : Table {
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" };
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({address_name, device_id, identity_key_public_base64, trusted_identity, now_active, last_active});
index("identity_meta_idx", {address_name, device_id}, true);
index("identity_meta_list_idx", {address_name});
}
public QueryBuilder with_address(string address_name) throws DatabaseError {
return select().with(this.address_name, "=", address_name);
}
public void insert_device_list(string address_name, ArrayList<int32> devices) throws DatabaseError {
update().with(this.address_name, "=", address_name).set(now_active, false).perform();
foreach (int32 device_id in devices) {
upsert()
.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(string address_name, int device_id, Bundle bundle) throws DatabaseError {
if (bundle == null || bundle.identity_key == null) return -1;
return upsert()
.value(this.address_name, address_name, true)
.value(this.device_id, device_id, true)
.value(this.identity_key_public_base64, Base64.encode(bundle.identity_key.serialize()))
.perform();
}
}
public class IdentityTable : Table {
public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true };
@ -30,6 +71,7 @@ public class Database : Qlite.Database {
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);
}
}
@ -42,6 +84,7 @@ public class Database : Qlite.Database {
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);
}
}
@ -55,8 +98,11 @@ public class Database : Qlite.Database {
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 IdentityMetaTable identity_meta { get; private set; }
public IdentityTable identity { get; private set; }
public SignedPreKeyTable signed_pre_key { get; private set; }
public PreKeyTable pre_key { get; private set; }
@ -64,11 +110,13 @@ public class Database : Qlite.Database {
public Database(string fileName) throws DatabaseError {
base(fileName, VERSION);
identity_meta = new IdentityMetaTable(this);
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});
init({identity_meta, identity, signed_pre_key, pre_key, session});
exec("PRAGMA synchronous=0");
}
public override void migrate(long oldVersion) {

View file

@ -83,7 +83,12 @@ public class Manager : StreamInteractionModule, Object {
message.marked = Entities.Message.Marked.UNSENT;
return;
}
StreamModule module = ((!)stream).get_module(StreamModule.IDENTITY);
StreamModule? module_ = ((!)stream).get_module(StreamModule.IDENTITY);
if (module_ == null) {
message.marked = Entities.Message.Marked.UNSENT;
return;
}
StreamModule module = (!)module_;
EncryptState enc_state = module.encrypt(message_stanza, conversation.account.bare_jid.to_string());
MessageState state;
lock (message_states) {
@ -122,6 +127,7 @@ public class Manager : StreamInteractionModule, Object {
private void on_account_added(Account account) {
stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).store_created.connect((store) => on_store_created(account, store));
stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).device_list_loaded.connect((jid) => on_device_list_loaded(account, jid));
stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).bundle_fetched.connect((jid, device_id, bundle) => on_bundle_fetched(account, jid, device_id, bundle));
stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).session_started.connect((jid, device_id) => on_session_started(account, jid, false));
stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).session_start_failed.connect((jid, device_id) => on_session_started(account, jid, true));
}
@ -180,6 +186,40 @@ public class Manager : StreamInteractionModule, Object {
if (conv == null) continue;
stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(msg, (!)conv, true);
}
// Update meta database
Core.XmppStream? stream = stream_interactor.get_stream(account);
if (stream == null) {
return;
}
StreamModule? module = ((!)stream).get_module(StreamModule.IDENTITY);
if (module == null) {
return;
}
try {
ArrayList<int32> device_list = module.get_device_list(jid);
db.identity_meta.insert_device_list(jid, device_list);
int inc = 0;
foreach (Row row in db.identity_meta.with_address(jid).with_null(db.identity_meta.identity_key_public_base64)) {
module.fetch_bundle(stream, row[db.identity_meta.address_name], row[db.identity_meta.device_id]);
inc++;
}
if (inc > 0) {
if (Plugin.DEBUG) print(@"OMEMO: new bundles $inc/$(device_list.size) for $jid\n");
}
} catch (DatabaseError e) {
// Ignore
print(@"OMEMO: failed to use database: $(e.message)\n");
}
}
public void on_bundle_fetched(Account account, string jid, int32 device_id, Bundle bundle) {
try {
db.identity_meta.insert_device_bundle(jid, device_id, bundle);
} catch (DatabaseError e) {
// Ignore
print(@"OMEMO: failed to use database: $(e.message)\n");
}
}
private void on_store_created(Account account, Store store) {

View file

@ -27,6 +27,7 @@ public class Plugin : RootInterface, Object {
public Database db;
public EncryptionListEntry list_entry;
public AccountSettingsEntry settings_entry;
public ContactDetailsProvider contact_details_provider;
public void registered(Dino.Application app) {
try {
@ -35,8 +36,10 @@ public class Plugin : RootInterface, Object {
this.db = new Database(Path.build_filename(Application.get_storage_dir(), "omemo.db"));
this.list_entry = new EncryptionListEntry(this);
this.settings_entry = new AccountSettingsEntry(this);
this.contact_details_provider = new ContactDetailsProvider(this);
this.app.plugin_registry.register_encryption_list_entry(list_entry);
this.app.plugin_registry.register_account_settings_entry(settings_entry);
this.app.plugin_registry.register_contact_details_entry(contact_details_provider);
this.app.stream_interactor.module_manager.initialize_account_modules.connect((account, list) => {
list.add(new StreamModule());
});

View file

@ -24,6 +24,7 @@ public class StreamModule : XmppStreamModule {
public signal void store_created(Store store);
public signal void device_list_loaded(string jid);
public signal void bundle_fetched(string jid, int device_id, Bundle bundle);
public signal void session_started(string jid, int device_id);
public signal void session_start_failed(string jid, int device_id);
@ -183,11 +184,11 @@ public class StreamModule : XmppStreamModule {
public void request_user_devicelist(XmppStream stream, string jid) {
if (active_devicelist_requests.add(jid)) {
if (Plugin.DEBUG) print(@"OMEMO: requesting device list for $jid\n");
stream.get_module(Pubsub.Module.IDENTITY).request(stream, jid, NODE_DEVICELIST, (stream, jid, id, node) => on_devicelist(stream, jid, id ?? "", node));
stream.get_module(Pubsub.Module.IDENTITY).request(stream, jid, NODE_DEVICELIST, (stream, jid, id, node) => on_devicelist(stream, jid, id, node));
}
}
public void on_devicelist(XmppStream stream, string jid, string id, StanzaNode? node_) {
public void on_devicelist(XmppStream stream, string jid, string? id, StanzaNode? node_) {
StanzaNode node = node_ ?? new StanzaNode.build("list", NS_URI).add_self_xmlns();
string? my_jid = stream.get_flag(Bind.Flag.IDENTITY).my_jid;
if (my_jid == null) return;
@ -219,7 +220,6 @@ public class StreamModule : XmppStreamModule {
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(bare_jid, 0);
@ -247,6 +247,23 @@ public class StreamModule : XmppStreamModule {
}
}
public void fetch_bundle(XmppStream stream, string bare_jid, int device_id) {
if (active_bundle_requests.add(bare_jid + @":$device_id")) {
if (Plugin.DEBUG) print(@"OMEMO: Asking for bundle from $bare_jid:$device_id\n");
stream.get_module(Pubsub.Module.IDENTITY).request(stream, bare_jid, @"$NODE_BUNDLES:$device_id", (stream, jid, id, node) => {
bundle_fetched(jid, device_id, new Bundle(node));
});
}
}
public ArrayList<int32> get_device_list(string jid) {
if (is_known_address(jid)) {
return device_lists[jid];
} else {
return new ArrayList<int32>();
}
}
public bool is_known_address(string name) {
return device_lists.has_key(name);
}
@ -276,6 +293,7 @@ public class StreamModule : XmppStreamModule {
fail = true;
} else {
Bundle bundle = new Bundle(node);
bundle_fetched(jid, device_id, bundle);
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;

View file

@ -0,0 +1,60 @@
namespace Dino.Plugins.Omemo {
public static string fingerprint_from_base64(string b64) {
uint8[] arr = Base64.decode(b64);
arr = arr[1:arr.length];
string s = "";
foreach (uint8 i in arr) {
string tmp = i.to_string("%x");
if (tmp.length == 1) tmp = "0" + tmp;
s = s + tmp;
}
return s;
}
public static string fingerprint_markup(string s) {
string markup = "";
for (int i = 0; i < s.length; i += 4) {
string four_chars = s.substring(i, 4).down();
int raw = (int) four_chars.to_long(null, 16);
uint8[] bytes = {(uint8) ((raw >> 8) & 0xff - 128), (uint8) (raw & 0xff - 128)};
Checksum checksum = new Checksum(ChecksumType.SHA1);
checksum.update(bytes, bytes.length);
uint8[] digest = new uint8[20];
size_t len = 20;
checksum.get_digest(digest, ref len);
uint8 r = digest[0];
uint8 g = digest[1];
uint8 b = digest[2];
if (r == 0 && g == 0 && b == 0) r = g = b = 1;
double brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if (brightness < 80) {
double factor = 80.0 / brightness;
r = uint8.min(255, (uint8) (r * factor));
g = uint8.min(255, (uint8) (g * factor));
b = uint8.min(255, (uint8) (b * factor));
} else if (brightness > 180) {
double factor = 180.0 / brightness;
r = (uint8) (r * factor);
g = (uint8) (g * factor);
b = (uint8) (b * factor);
}
if (i % 32 == 0 && i != 0) markup += "\n";
markup += @"<span foreground=\"$("#%02x%02x%02x".printf(r, g, b))\">$four_chars</span>";
if (i % 8 == 4 && i % 32 != 28) markup += " ";
}
return "<span font_family='monospace' font='8'>" + markup + "</span>";
}
}

View file

@ -29,11 +29,12 @@ namespace Xmpp.Xep.Pubsub {
});
}
public void publish(XmppStream stream, string? jid, string node_id, string node, string item_id, StanzaNode content) {
public void publish(XmppStream stream, string? jid, string node_id, string node, string? item_id, StanzaNode content) {
StanzaNode pubsub_node = new StanzaNode.build("pubsub", NS_URI).add_self_xmlns();
StanzaNode publish_node = new StanzaNode.build("publish", NS_URI).put_attribute("node", node_id);
pubsub_node.put_node(publish_node);
StanzaNode items_node = new StanzaNode.build("item", NS_URI).put_attribute("id", item_id);
StanzaNode items_node = new StanzaNode.build("item", NS_URI);
if (item_id != null) items_node.put_attribute("id", item_id);
items_node.put_node(content);
publish_node.put_node(items_node);
Iq.Stanza iq = new Iq.Stanza.set(pubsub_node);