| author | Matthew Wild
<mwild1@gmail.com> 2024-12-02 14:40:26 UTC |
| committer | Matthew Wild
<mwild1@gmail.com> 2025-04-15 15:38:27 UTC |
| parent | 72a19608040ab5633e85f599370b6e8f8156d023 |
| snikket/Chat.hx | +11 | -5 |
| snikket/OMEMO.hx | +414 | -0 |
| snikket/SignalProtocol.hx | +92 | -0 |
| snikket/persistence/IDB.js | +3 | -1 |
diff --git a/snikket/Chat.hx b/snikket/Chat.hx index dc75e76..44120e4 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -78,7 +78,10 @@ abstract class Chat { private var notificationSettings: Null<{reply: Bool, mention: Bool}> = null; @:allow(snikket) - private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) { + private var omemoContactDeviceIDs: Array<Int> = []; + + @:allow(snikket) + private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null, omemoContactDeviceIDs: Array<Int> = null) { this.client = client; this.stream = stream; this.persistence = persistence; @@ -89,6 +92,7 @@ abstract class Chat { this.readUpToId = readUpToId; this.readUpToBy = readUpToBy; this.displayName = chatId; + this.omemoContactDeviceIDs = omemoContactDeviceIDs ?? []; } @:allow(snikket) @@ -716,8 +720,8 @@ abstract class Chat { #end class DirectChat extends Chat { @:allow(snikket) - private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) { - super(client, stream, persistence, chatId, uiState, isBlocked, extensions, readUpToId, readUpToBy); + private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null, omemoContactDeviceIDs: Array<Int> = null) { + super(client, stream, persistence, chatId, uiState, isBlocked, extensions, readUpToId, readUpToBy, omemoContactDeviceIDs); } @HaxeCBridge.noemit // on superclass as abstract @@ -1564,12 +1568,13 @@ class SerializedChat { public final readUpToId:Null<String>; public final readUpToBy:Null<String>; public final disco:Null<Caps>; + public final omemoContactDeviceIDs: Array<Int>; public final klass:String; public final notificationsFiltered: Null<Bool>; public final notifyMention: Bool; public final notifyReply: Bool; - public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, disco: Null<Caps>, klass: String) { + public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) { this.chatId = chatId; this.trusted = trusted; this.avatarSha1 = avatarSha1; @@ -1584,6 +1589,7 @@ class SerializedChat { this.notifyMention = notifyMention; this.notifyReply = notifyReply; this.disco = disco; + this.omemoContactDeviceIDs = omemoContactDeviceIDs; this.klass = klass; } @@ -1593,7 +1599,7 @@ class SerializedChat { var mention = notifyMention; final chat = if (klass == "DirectChat") { - new DirectChat(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy); + new DirectChat(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy, omemoContactDeviceIDs); } else if (klass == "Channel") { final channel = new Channel(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy); channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]); diff --git a/snikket/OMEMO.hx b/snikket/OMEMO.hx new file mode 100644 index 0000000..5bc2de6 --- /dev/null +++ b/snikket/OMEMO.hx @@ -0,0 +1,414 @@ +package snikket; + +import snikket.queries.PubsubGet; +import snikket.queries.PubsubPublish; + +import haxe.crypto.Base64; +import haxe.io.Bytes; + +import thenshim.Promise; +import thenshim.PromiseTools; + +using snikket.SignalProtocol; + +@:structInit +class OMEMOBundleSignedPreKey { + public final id: Int; + public final public_key: String; + public final signature: String; +} + +@:structInit +class OMEMOBundle { + public final identity_key: String; + public final device_id: Int; + public final prekeys: Array<PublicPreKey>; + public final signed_prekey: OMEMOBundleSignedPreKey; + + public function new(identity_key:String, device_id:Int, prekeys:Array<PublicPreKey>, signed_prekey:OMEMOBundleSignedPreKey) { + this.identity_key = identity_key; + this.device_id = device_id; + this.prekeys = prekeys; + this.signed_prekey = signed_prekey; + } + + public function toXml():Stanza { + var bundleTag = new Stanza("bundle", { xmlns: "eu.siacs.conversations.axolotl" }); + bundleTag.textTag("signedPreKeyPublic", signed_prekey.public_key, { signedPreKeyId: Std.string(signed_prekey.id) }); + bundleTag.textTag("signedPreKeySignature", signed_prekey.signature); + bundleTag.textTag("identityKey", identity_key); + + bundleTag.tag("prekeys"); + for (prekey in prekeys) { + bundleTag.textTag("preKeyPublic", prekey.pubKey, { preKeyId: Std.string(prekey.keyId) }); + } + bundleTag.up(); + return bundleTag; + } +} + +class OMEMO { + private final client: Client; + private final persistence: Persistence; + + // Track the status of our bundle state locally + private final bundleLocalState:FSM; + + // Track the status of our account's PEP node + private final bundlePublicState:FSM; + + private var bundle: OMEMOBundle; + + // An array of all our device IDs on our account + public var deviceList:Array<Int>; + + // Recommended number of prekeys, per the XEP + private final NUM_PREKEYS = 100; + private static final publicNodeConfig:PubsubConfig = { + max_items: 1, + access_model: "open", + publish_model: "publishers", + persist_items: true, + send_last_published_item: "on_sub_and_presence", + }; + + public function new(client_: Client, persistence_: Persistence) { + client = client_; + persistence = persistence_; + + bundleLocalState = new FSM({ + transitions: [ + { name: "loaded", from: ["loading"], to: "ok" }, + { name: "missing", from: ["loading"], to: "creating" }, + { name: "created", from: ["creating"], to: "ok" }, + ], + state_handlers: [ + "loading" => loadBundle, + "creating" => createLocalBundle, + ], + transition_handlers: [ + ], + }, "loading"); + + bundlePublicState = new FSM({ + transitions: [ + { name: "verify", from: ["unverified"], to: "verifying" }, + { name: "needs-update", from: ["unverified", "verifying", "waiting", "updating", "ok"], to: "updating" }, + { name: "wait", from: ["updating"], to: "waiting" }, + { name: "updated", from: ["updating"], to: "ok" }, + { name: "verified", from: ["unverified", "verifying"], to: "ok" }, + ], + state_handlers: [ + "verifying" => verifyPublishedBundle, + "waiting" => waitForBundleReady, + "updating" => updatePublishedBundle, + ], + transition_handlers: [ + ], + }, "unverified"); + + client.on("session-started", function (event) { + bundlePublicState.event("verify"); + return EventHandled; + }); + } + + private function loadBundle(event) { + var bundleSignedPreKey:OMEMOBundleSignedPreKey; + var newBundle = { + identity_key: null, + device_id: null, + prekeys: null, + signed_prekey: null, + }; + + final pDeviceId = new Promise(function (resolve, reject) { + persistence.getOmemoId(client.accountId(), resolve); + }).then(function (storedDeviceId) { + if(storedDeviceId == null) { + // We don't have an OMEMO identity, so we need + // to create all our state and publish it + return false; + } + trace("Using existing OMEMO identity"); + newBundle.device_id = storedDeviceId; + return true; + }); + + final pIdentityKey = new Promise(function (resolve, reject) { + persistence.getOmemoIdentityKey(client.accountId(), resolve); + }).then(function (storedIdentityKey) { + if(storedIdentityKey == null) { + trace("No identity key stored"); + this.bundleLocalState.event("missing"); + return false; + } + trace("Loaded identity key"); + newBundle.identity_key = Base64.encode(Bytes.ofData(storedIdentityKey.pubKey)); + return true; + }); + + final pSignedPreKey = new Promise(function (resolve, reject) { + persistence.getOmemoSignedPreKey(client.accountId(), 1, resolve); + }).then(function (signedPreKey) { + if(signedPreKey == null) { + trace("No signed prekey stored"); + return false; + } + trace("Loaded signed prekey"); + newBundle.signed_prekey = signedPreKey; + return true; + }); + + final pPreKeys = new Promise(function (resolve, reject) { + persistence.getOmemoPreKeys(client.accountId(), resolve); + }).then(function (prekeys) { + // Always an array (just empty if no keys) + // FIXME: bundle should contain Array<PublicPreKey> rather then PreKeyPair!! + newBundle.prekeys = [ + for(i in 0...prekeys.length) { + { + keyId: i+1, + pubKey: Base64.encode(Bytes.ofData(prekeys[i].pubKey)), + }; + } + ]; + trace("Loaded "+Std.string(prekeys.length)+" prekeys"); + return true; + }); + + PromiseTools.all([pDeviceId, pIdentityKey, pSignedPreKey, pPreKeys]).then(function (results) { + if(results.contains(false) || !bundleLocalState.can("loaded")) { + trace("Problems loading OMEMO bundle or interrupted"); + this.bundleLocalState.event("missing"); + return false; + } + trace("OMEMO bundle successfully loaded from storage"); + this.bundle = new OMEMOBundle( + newBundle.identity_key, + newBundle.device_id, + newBundle.prekeys, + newBundle.signed_prekey + ); + bundleLocalState.event("loaded"); + return true; + }); + } + + + private function createLocalBundle(event) { + trace("Generating OMEMO identity for new device"); + buildBundle().then(function (ok:Bool) { + if (!ok || !bundleLocalState.can("created")) { + trace("Bundle creation failed"); + return; + } + bundleLocalState.event("created"); + // Signal that we need to publish the new bundle + this.bundlePublicState.event("needs-update"); + }); + } + + // Wait for our local bundle to be ready for publication + private function waitForBundleReady(event) { + if(bundleLocalState.getState() == "ok") { + // No need to wait! + bundlePublicState.event("needs-update"); + return; + } + + bundleLocalState.once("enter/ok", function (event) { + bundlePublicState.event("needs-update"); + return EventHandled; + }); + } + + private function verifyPublishedBundle(event) { + trace("Verifying published OMEMO bundle"); + final deviceListGet = new PubsubGet(null, "eu.siacs.conversations.axolotl.devicelist"); + deviceListGet.onFinished(() -> { + final devices = deviceIdsFromPubsubItems(deviceListGet.getResult()); + if(devices != null && devices.contains(this.bundle.device_id)) { + bundlePublicState.event("verified"); + } else { + bundlePublicState.event("needs-update"); + } + }); + client.sendQuery(deviceListGet); + } + + private function updatePublishedBundle(event) { + if(bundleLocalState.getState() != "ok") { + trace("Can't publish yet - waiting for local bundle"); + bundlePublicState.event("wait"); + return; + } + trace("Going to publish our bundle..."); + publishBundle(); + } + + private function deviceIdsFromPubsubItems(items:Array<Stanza>):Null<Array<Int>> { + if(items.length == 0) { + return null; + } + var devicelist = items[0].getChild("list", "eu.siacs.conversations.axolotl"); + if (devicelist == null) { + return null; + } + + var devices = []; + for (device in devicelist.allTags("device", null)) { + var device_id = Std.parseInt(device.attr.get("id")); + if (device_id != null) { + devices.push(device_id); + } + } + return devices; + } + + // Called when we receive an updated device list for our own account + public function onAccountUpdatedDeviceList(items:Array<Stanza>) { + trace("OMEMO: onAccountUpdatedDeviceList"); + // XEP-0384 (v0.3.0 section 4.3): + // To mitigate this, devices MUST check that their own device ID is contained in the list + // whenever they receive a PEP update from their own account. If they have been removed, + // they MUST reannounce themselves. + var devices = deviceIdsFromPubsubItems(items); + if(devices == null || !devices.contains(bundle.device_id)) { + trace("Incomplete or empty device list"); + publishDeviceList(); + } else { + trace("Excellent... this device is already in the published device list"); + } + } + + // Called when one of our contacts has published an updated device list + public function onContactUpdatedDeviceList(contact:JID, items:Array<Stanza>) { + trace("OMEMO: onContactUpdatedDeviceList: "+items[0]); + var identifier = contact.asBare().asString(); + var chat = client.getDirectChat(identifier); + var devices = deviceIdsFromPubsubItems(items); + if(devices != null) { + chat.omemoContactDeviceIDs = devices; + persistence.storeChat(client.accountId(), chat); + } + } + + + private function publishDeviceList() { + if(deviceList == null) { + deviceList = [bundle.device_id]; + } else if(!deviceList.contains(bundle.device_id)) { + deviceList.push(bundle.device_id); + } + var deviceListTag = new Stanza("list", { xmlns: "eu.siacs.conversations.axolotl" }); + for(deviceId_ in deviceList) { + deviceListTag.tag("device", { id: Std.string(deviceId_) }).up(); + } + var publish = new PubsubPublish(null, "eu.siacs.conversations.axolotl.devicelist", "current", deviceListTag, publicNodeConfig); + publish.onFinished( + () -> { + if (!publish.success) { + trace("Failed to publish updated OMEMO device list: "+publish.error.condition); + } + trace("OMEMO device list published!"); + bundlePublicState.event("updated"); + } + ); + client.sendQuery(publish); + } + + private function publishBundle() { + final bundleTag = bundle.toXml(); + final nodeName = "eu.siacs.conversations.axolotl.bundles:" + Std.string(bundle.device_id); + final publish = new PubsubPublish(null, nodeName, "current", bundleTag, publicNodeConfig); + publish.onFinished( + () -> { + if (!publish.success) { + trace("Failed to publish our OMEMO device bundle: " + publish.error.condition); + } else { + trace("Published bundle!"); + if(deviceList == null || !deviceList.contains(bundle.device_id)) { + trace("Need to also publish updated devicelist"); + publishDeviceList(); + } + } + } + ); + client.sendQuery(publish); + } + + // Stuff that touches libsignal + + // Build and store an OMEMO identity bundle + private function buildBundle():Promise<Bool> { + final deviceId = KeyHelper.generateRegistrationId(); // FIXME: Check for collision + var identityKeyPair:IdentityKeyPair; + var signedPreKey:SignedPreKey; + var prekeys:Array<PublicPreKey>; + + final identityKeyPairPromise:Promise<IdentityKeyPair> = KeyHelper.generateIdentityKeyPair(); + + final doneIdentityStorage:Promise<Bool> = identityKeyPairPromise.then(function (keypair:IdentityKeyPair):Bool { + identityKeyPair = keypair; + persistence.storeOmemoId(client.accountId(), deviceId); + persistence.storeOmemoIdentityKey(client.accountId(), keypair); + return true; + }); + + final preKeysPromise:Promise<Array<PublicPreKey>> = doneIdentityStorage.then(cast generatePreKeys); + + return preKeysPromise.then(function (prekeys_:Array<PublicPreKey>):Promise<SignedPreKey> { + // Store prekeys array for publication in a moment + prekeys = prekeys_; + + return KeyHelper.generateSignedPreKey(identityKeyPair, 0); + }).then(cast function (signedPreKey:SignedPreKey):Bool { + // store.js:283 + final stored_signed_prekey:OMEMOBundleSignedPreKey = { + id: signedPreKey.keyId, + public_key: Base64.encode(Bytes.ofData(signedPreKey.keyPair.pubKey)), + signature: Base64.encode(Bytes.ofData(signedPreKey.signature)), + }; + persistence.storeOmemoSignedPreKey(client.accountId(), stored_signed_prekey); + + this.bundle = { + identity_key: Base64.encode(Bytes.ofData(identityKeyPair.pubKey)), + device_id: deviceId, + prekeys: prekeys, + signed_prekey: stored_signed_prekey, + }; + return true; + }); + } + + private function generatePreKeys(_:Bool):Promise<Array<PublicPreKey>> { + final amount = NUM_PREKEYS; + final keys = []; + + final generatedPreKeys:Promise<Array<PreKey>> = PromiseTools.all([ + for(i in 1...(amount+1)) { + trace("Generating prekey "+Std.string(i)); + KeyHelper.generatePreKey(i); + } + ]); + + final publicStoredPreKeys:Promise<Array<PublicPreKey>> = cast generatedPreKeys.then(cast function (prekeys:Array<PreKey>):Array<PublicPreKey> { + for(prekey in prekeys) { + // Store the full keypair + persistence.storeOmemoPreKey(client.accountId(), prekey.keyId, prekey.keyPair); + } + return [ + for(prekey in prekeys) { + // Emit the base64 public part for the application to publish + { + keyId: prekey.keyId, + pubKey: Base64.encode(Bytes.ofData(prekey.keyPair.pubKey)) + }; + } + ]; + }); + + return publicStoredPreKeys; + } +} diff --git a/snikket/SignalProtocol.hx b/snikket/SignalProtocol.hx new file mode 100644 index 0000000..ea2305e --- /dev/null +++ b/snikket/SignalProtocol.hx @@ -0,0 +1,92 @@ +package snikket; + +import haxe.io.BytesData; + +using thenshim.Promise; + +// Types and methods used/provided by libsignal + +@:expose +typedef IdentityPublicKey = BytesData; + +@:expose +typedef IdentityKeyPair = { + var privKey: BytesData; + var pubKey: BytesData; +} + +@:expose +typedef PublicPreKey = { + var keyId: Int; + // Base64 representation of the public key + var pubKey: String; +} + +@:expose +typedef PreKeyPair = { + var privKey: BytesData; + var pubKey: BytesData; +} + +@:expose +typedef PreKey = { + var keyId: Int; + var keyPair: PreKeyPair; +} + +@:expose +typedef SignedPreKey = { + var keyId: Int; + var keyPair: PreKeyPair; + var signature: BytesData; +} + +// Not sure what the fields are for this one +typedef SignalSession = Dynamic; + +@:native("libsignal.KeyHelper") +extern class KeyHelper { + static function generateRegistrationId():Int; + static function generatePreKey(keyId: Int):Promise<PreKey>; + static function generateIdentityKeyPair():Promise<IdentityKeyPair>; + static function generateSignedPreKey(identityKeyPair: IdentityKeyPair, keyId: Int):Promise<SignedPreKey>; +} + +abstract class SignalProtocolStore { + static final Direction = { + SENDING: 1, + RECEIVING: 2, + }; + // Return our identity keypair + abstract public function getIdentityKeyPair():IdentityKeyPair; + + // Return our "device id" + abstract public function getLocalRegistrationId():Int; + + // Return a boolean indicating whether we trust this identity + abstract public function isTrustedIdentity(identifier: String, identityKey: IdentityPublicKey, _direction: Int):Promise<Bool>; + + abstract public function loadIdentityKey(identifier: String):Promise<IdentityPublicKey>; + + abstract public function saveIdentity(identifier: String, identityKey:IdentityPublicKey):Promise<Bool>; + + abstract public function loadPreKey(keyId:Int):Promise<PreKeyPair>; + + abstract public function storePreKey(keyId:Int, keyPair:PreKeyPair):Promise<Bool>; + + abstract public function removePreKey(keyId:Int):Promise<Bool>; + + abstract public function loadSignedPreKey(keyId:Int):Promise<SignedPreKey>; + + abstract public function storeSignedPreKey(keyId:Int, keyPair:SignedPreKey):Promise<Bool>; + + abstract public function removeSignedPreKey(keyId:Int):Promise<Bool>; + + abstract public function loadSession(identifier:String):Promise<SignalSession>; + + abstract public function storeSession(identifier:String, session:SignalSession):Promise<Bool>; + + abstract public function removeSession(identifier:String):Promise<Bool>; + + abstract public function removeAllSessions(identifier:String):Promise<Bool>; +} diff --git a/snikket/persistence/IDB.js b/snikket/persistence/IDB.js index b64f137..0cd4f1e 100644 --- a/snikket/persistence/IDB.js +++ b/snikket/persistence/IDB.js @@ -242,7 +242,8 @@ export default (dbname, media, tokenize, stemmer) => { readUpToBy: chat.readUpToBy, notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null, disco: chat.disco, - class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat") + omemoDevices: chat.omemoContactDeviceIDs, + class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat") }); } }, @@ -270,6 +271,7 @@ export default (dbname, media, tokenize, stemmer) => { r.notificationSettings?.mention, r.notificationSettings?.reply, r.disco ? new snikket.Caps(r.disco.node, r.disco.identities, r.disco.features) : null, + r.omemoDevices || [], r.class ))); })().then(callback);