From 210eaf1342e9a189b7926b0c2bf6da00da0fbd73 Mon Sep 17 00:00:00 2001 From: AVHIRAL <93483715+avhiral@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:53:41 +0300 Subject: [PATCH] OMEMO --- plugins/omemo/README.md | 85 ++ plugins/omemo/SECURITY.md | 19 + plugins/omemo/pom.xml | 50 ++ .../spark/plugin/omemo/AvhOmemoPlugin.java | 770 ++++++++++++++++++ .../omemo/IntegratedChatController.java | 404 +++++++++ .../plugin/omemo/OmemoDiagnosticService.java | 387 +++++++++ .../plugin/omemo/OmemoFallbackSuppressor.java | 69 ++ .../spark/plugin/omemo/OmemoRuntime.java | 213 +++++ .../plugin/omemo/PersistentTrustCallback.java | 101 +++ plugins/omemo/src/main/plugin/plugin.xml | 11 + pom.xml | 1 + 11 files changed, 2110 insertions(+) create mode 100644 plugins/omemo/README.md create mode 100644 plugins/omemo/SECURITY.md create mode 100644 plugins/omemo/pom.xml create mode 100644 plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/AvhOmemoPlugin.java create mode 100644 plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/IntegratedChatController.java create mode 100644 plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoDiagnosticService.java create mode 100644 plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java create mode 100644 plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoRuntime.java create mode 100644 plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java create mode 100644 plugins/omemo/src/main/plugin/plugin.xml diff --git a/plugins/omemo/README.md b/plugins/omemo/README.md new file mode 100644 index 000000000..d96d6b01f --- /dev/null +++ b/plugins/omemo/README.md @@ -0,0 +1,85 @@ +# AVHIRAL Spark OMEMO — Principal 0.14.2 + +Le journal 0.14.0 confirme que le déchiffrement fonctionne : + +```text +RUNTIME DECRYPTED MESSAGE +body=Bonjour mon petit +``` + +Le défaut restant était entièrement dans l'intégration graphique Spark. + +## Deux causes corrigées + +### 1. Le fallback était encore affiché + +Le listener Smack synchrone supprimait bien le corps, mais Spark avait déjà +programmé le traitement de la stanza dans son propre `Runnable`. + +La 0.14.1 utilise maintenant les deux API natives de Spark : + +```java +ChatManager.addMessageFilter(...) +ChatManager.addTranscriptWindowInterceptor(...) +``` + +Le filtre retire le corps avant la persistance de la conversation. +L'intercepteur bloque l'entrée graphique de secours juste avant +`TranscriptWindow.insertMessage()`. + +### 2. Le texte déchiffré n'était pas dessiné + +L'ancienne méthode utilisait : + +```java +room.addToTranscript(String, String, String, Date) +``` + +Dans Spark 3.0.2, cette méthode alimente uniquement la liste d'historique. +Elle ne dessine rien dans la fenêtre. + +La 0.14.1 utilise maintenant : + +```java +room.getTranscriptWindow().insertMessage(...) +``` + +puis ajoute le message à l'historique. + +## Résultat attendu + +Monal envoie : + +```text +Bonjour mon petit +``` + +Spark affiche uniquement : + +```text +david 🔒: Bonjour mon petit +``` + +Le texte suivant ne doit plus apparaître : + +```text +[This message is OMEMO encrypted] +``` + + +## Correctif 0.14.2 + +La version 0.14.1 utilisait : + +```java +SparkManager.getSessionManager() +``` + +dans `IntegratedChatController.java`, mais l'import suivant manquait : + +```java +import org.jivesoftware.spark.SparkManager; +``` + +La 0.14.2 ajoute cet import et renforce `verify-source.ps1` pour détecter +automatiquement cette régression avant compilation. diff --git a/plugins/omemo/SECURITY.md b/plugins/omemo/SECURITY.md new file mode 100644 index 000000000..8502243d3 --- /dev/null +++ b/plugins/omemo/SECURITY.md @@ -0,0 +1,19 @@ +# Sécurité + +## Modèle de menace couvert + +- interception réseau ; +- lecture des messages par le serveur XMPP ; +- usurpation d'un appareil non vérifié, signalée par les mécanismes OMEMO. + +## Limites de l'alpha + +- le stockage local OMEMO n'est pas encore enveloppé avec Windows DPAPI ; +- l'interface de vérification d'empreinte n'est pas encore intégrée ; +- les conversations de groupe ne sont pas implémentées ; +- les fichiers joints ne sont pas chiffrés par ce plugin ; +- aucune garantie n'est donnée avant audit. + +## Règle critique + +Le plugin refuse l'envoi lorsqu'une session OMEMO ne peut pas être établie. Il ne doit jamais faire de repli silencieux en texte clair. diff --git a/plugins/omemo/pom.xml b/plugins/omemo/pom.xml new file mode 100644 index 000000000..06fcb5d31 --- /dev/null +++ b/plugins/omemo/pom.xml @@ -0,0 +1,50 @@ + + 4.0.0 + + + org.igniterealtime.spark.plugins + plugin + 3.1.0-SNAPSHOT + ../plugin/pom.xml + + + omemo + 0.14.2 + Spark OMEMO + OMEMO message encryption. + + + UTF-8 + + + + AVHIRAL + + Author + + + + + + + org.igniterealtime.smack + smack-omemo + ${dependency.smack.version} + + + org.igniterealtime.smack + smack-omemo-signal + ${dependency.smack.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/AvhOmemoPlugin.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/AvhOmemoPlugin.java new file mode 100644 index 000000000..4a8c3ae98 --- /dev/null +++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/AvhOmemoPlugin.java @@ -0,0 +1,770 @@ +package org.jivesoftware.spark.plugin.omemo; + +import java.io.File; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; + +import org.jivesoftware.smack.packet.MessageBuilder; +import org.jivesoftware.spark.ChatManager; +import org.jivesoftware.spark.SparkManager; +import org.jivesoftware.spark.plugin.Plugin; +import org.jivesoftware.spark.ui.ChatRoom; +import org.jivesoftware.spark.ui.ChatRoomListener; +import org.jivesoftware.spark.ui.MessageFilter; +import org.jivesoftware.spark.ui.transctipt.TranscriptWindow; +import org.jivesoftware.spark.ui.transctipt.TranscriptWindowInterceptor; +import org.jivesoftware.spark.ui.rooms.ChatRoomImpl; +import org.jivesoftware.smack.AbstractXMPPConnection; +import org.jivesoftware.smack.provider.ProviderManager; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.filter.StanzaTypeFilter; +import org.jivesoftware.smackx.omemo.provider.OmemoBundleVAxolotlProvider; +import org.jivesoftware.smackx.omemo.provider.OmemoDeviceListVAxolotlProvider; +import org.jivesoftware.smackx.omemo.provider.OmemoVAxolotlProvider; +import org.jivesoftware.smackx.omemo.element.OmemoElement_VAxolotl; +import org.jxmpp.jid.EntityBareJid; +import org.jxmpp.jid.impl.JidCreate; + +import static org.jivesoftware.smackx.omemo.util.OmemoConstants.OMEMO_NAMESPACE_V_AXOLOTL; + +public final class AvhOmemoPlugin + implements Plugin, MessageFilter, ChatRoomListener, OmemoRuntime.Listener, OmemoFallbackSuppressor.Listener, TranscriptWindowInterceptor { + + private static final Logger LOG = + Logger.getLogger(AvhOmemoPlugin.class.getName()); + + private final Map controllers = + new HashMap(); + + private JMenu avhiralMenu; + private OmemoRuntime runtime; + private OmemoDiagnosticService diagnostic; + private OmemoFallbackSuppressor fallbackSuppressor; + + @Override + public void initialize() { + /* + * Spark ne charge pas automatiquement le fichier de configuration + * Smack du module smack-omemo contenu dans un Sparkplug. + * + * Sans ces providers, les charges OMEMO et PEP sont décodées comme + * SimplePayload. Conséquences : + * - ClassCastException sur la liste des appareils ; + * - aucun appel à OmemoMessageListener ; + * - affichage du corps de repli + * "[This message is OMEMO encrypted]". + * + * L'enregistrement doit intervenir avant le traitement de toute + * stanza entrante. + */ + registerOmemoProviders(); + + ChatManager.getInstance().addChatRoomListener(this); + ChatManager.getInstance().addMessageFilter(this); + ChatManager.getInstance().addTranscriptWindowInterceptor(this); + installMenu(); + showInstallationNoticeOnce(); + warmUpRuntimeAsync(); + } + + private static void registerOmemoProviders() { + final String legacyNamespace = OMEMO_NAMESPACE_V_AXOLOTL; + ProviderManager.addExtensionProvider("encrypted", legacyNamespace, new OmemoVAxolotlProvider()); + ProviderManager.addExtensionProvider("list", legacyNamespace, new OmemoDeviceListVAxolotlProvider()); + ProviderManager.addExtensionProvider("bundle", legacyNamespace, new OmemoBundleVAxolotlProvider()); + + LOG.info("Providers OMEMO legacy enregistrés dans Smack."); + } + + /** + * Initialise OMEMO silencieusement dès que Spark est authentifié. + *

+ * Cela supprime le besoin d'ouvrir manuellement le diagnostic avant + * d'utiliser le cadenas dans une conversation. + */ + private void warmUpRuntimeAsync() { + Thread worker = new Thread(() -> { + for (int attempt = 0; attempt < 30; attempt++) { + try { + AbstractXMPPConnection connection = + SparkManager.getConnection(); + + if (connection != null + && connection.isAuthenticated()) { + installFallbackSuppressor(connection); + ensureRuntime(); + startWireDiagnosticSilently(); + LOG.info("Runtime OMEMO préinitialisé."); + return; + } + + Thread.sleep(1000L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } catch (Throwable error) { + /* + * Le préchauffage ne doit jamais bloquer Spark + * ni afficher une fenêtre. L'erreur reste + * disponible dans le journal, puis le clic sur + * le cadenas pourra réessayer. + */ + reportError( + "Préinitialisation OMEMO impossible", + error); + return; + } + } + }, + "AVHIRAL-OMEMO-Warmup"); + + worker.setDaemon(true); + worker.start(); + } + + private void installMenu() { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + avhiralMenu = new JMenu("AVHIRAL"); + JMenuItem diagnostic = new JMenuItem("Diagnostic OMEMO"); + diagnostic.addActionListener( + new java.awt.event.ActionListener() { + @Override + public void actionPerformed( + java.awt.event.ActionEvent event) { + showDiagnostics(); + } + }); + + JMenuItem purge = new JMenuItem("Republier mes appareils OMEMO"); + purge.addActionListener(event -> purgeOwnDevices()); + JMenuItem startDiagnostic = new JMenuItem("Démarrer diagnostic filaire"); + + startDiagnostic.addActionListener(event -> startWireDiagnostic()); + + JMenuItem stopDiagnostic = new JMenuItem("Arrêter diagnostic filaire"); + stopDiagnostic.addActionListener(event -> stopWireDiagnostic()); + + JMenuItem openDiagnostic = new JMenuItem("Ouvrir journal filaire"); + openDiagnostic.addActionListener(event -> openWireDiagnostic()); + + JMenuItem clearDiagnostic = new JMenuItem("Effacer journal filaire"); + clearDiagnostic.addActionListener(event -> clearWireDiagnostic()); + + avhiralMenu.add(diagnostic); + avhiralMenu.add(purge); + avhiralMenu.addSeparator(); + avhiralMenu.add(startDiagnostic); + avhiralMenu.add(stopDiagnostic); + avhiralMenu.add(openDiagnostic); + avhiralMenu.add(clearDiagnostic); + + SparkManager.getMainWindow().getJMenuBar().add(avhiralMenu); + SparkManager.getMainWindow().getJMenuBar().revalidate(); + SparkManager.getMainWindow().getJMenuBar().repaint(); + } + }); + } + + @Override + public void chatRoomOpened(ChatRoom room) { + attachRoom(room); + } + + @Override + public void chatRoomActivated(ChatRoom room) { + attachRoom(room); + } + + private synchronized void attachRoom(ChatRoom room) { + if (!(room instanceof ChatRoomImpl)) { + return; + } + if (controllers.containsKey(room)) { + return; + } + IntegratedChatController controller = new IntegratedChatController((ChatRoomImpl) room, this); + controllers.put(room, controller); + } + + @Override + public synchronized void chatRoomClosed(ChatRoom room) { + detachRoom(room); + } + + @Override + public synchronized void chatRoomLeft(ChatRoom room) { + detachRoom(room); + } + + private void detachRoom(ChatRoom room) { + IntegratedChatController controller = controllers.remove(room); + if (controller != null) { + controller.dispose(); + } + } + + @Override + public void userHasJoined(ChatRoom room, String user) { + // Non utilisé pour les conversations privées. + } + + @Override + public void userHasLeft(ChatRoom room, String user) { + // Non utilisé pour les conversations privées. + } + + + private synchronized void installFallbackSuppressor(AbstractXMPPConnection connection) { + if (fallbackSuppressor != null) { + return; + } + fallbackSuppressor = new OmemoFallbackSuppressor(this); + /* + * Le listener synchrone intervient avant la chaîne asynchrone de Spark + * qui ajoute le corps du message au transcript. + */ + connection.addSyncStanzaListener(fallbackSuppressor, StanzaTypeFilter.MESSAGE); + LOG.info("Suppresseur synchrone du fallback OMEMO installé."); + } + + private synchronized void removeFallbackSuppressor() { + AbstractXMPPConnection connection = SparkManager.getConnection(); + if (connection != null && fallbackSuppressor != null) { + connection.removeSyncStanzaListener(fallbackSuppressor); + } + fallbackSuppressor = null; + } + + @Override + public void onFallbackSuppressed(String from, String stanzaId, String originalBody) { + LOG.info("Fallback OMEMO supprimé : from=" + from + ", stanzaId=" + stanzaId); + if (diagnostic != null) { + diagnostic.logRuntimeEvent( + "FALLBACK SUPPRESSED", + "from=" + from + "\n" + + "stanzaId=" + stanzaId + "\n" + + "body=" + originalBody); + } + } + + public synchronized void ensureRuntime() throws Exception { + if (runtime != null && runtime.isReady()) { + return; + } + + AbstractXMPPConnection connection = + SparkManager.getConnection(); + + if (connection == null || !connection.isAuthenticated()) { + throw new IllegalStateException( + "Spark n'est pas authentifié."); + } + + installFallbackSuppressor(connection); + File base = new File( + System.getenv("APPDATA") != null + ? System.getenv("APPDATA") + : System.getProperty("user.home")); + + File store = new File( + new File(base, "Spark"), + "avhiral-omemo"); + + runtime = new OmemoRuntime(connection, store); + runtime.addListener(this); + runtime.initialize(); + } + + public synchronized OmemoRuntime getRuntime() { + return runtime; + } + + + private static boolean containsLegacyOmemo(MessageBuilder message) { + return message != null && message.hasExtension(OmemoElement_VAxolotl.NAMESPACE); + } + + private static boolean containsLegacyOmemo(Message message) { + return message != null && message.hasExtension(OmemoElement_VAxolotl.NAMESPACE); + } + + /** + * Dernière barrière juste avant l'affichage graphique par + * TranscriptWindow.insertMessage(). + */ + @Override + public boolean isMessageIntercepted(TranscriptWindow window, String userid, Message message) { + if (!containsLegacyOmemo(message)) { + return false; + } + + String body = message.getBody(); + if (body == null || "[This message is OMEMO encrypted]".equals(body.trim())) { + if (diagnostic != null) { + diagnostic.logRuntimeEvent( + "TRANSCRIPT FALLBACK BLOCKED", + "userid=" + userid + "\n" + + "stanzaId=" + message.getStanzaId() + "\n" + + "body=" + body); + } + return true; + } + return false; + } + + @Override + public void onSecureMessage(final String from, final String body) { + if (diagnostic != null) { + diagnostic.logRuntimeEvent("DECRYPTED MESSAGE", "from=" + from + "\n" + "body=" + body); + } + SwingUtilities.invokeLater(() -> { + try { + EntityBareJid jid = JidCreate.entityBareFrom(from); + ChatRoom existing = null; + for (ChatRoom room : controllers.keySet()) { + if (room.getBareJid() != null + && room.getBareJid().equals(jid)) { + existing = room; + break; + } + } + if (existing == null) { + existing = ChatManager.getInstance().getChatRoom(jid); + } + + attachRoom(existing); + IntegratedChatController controller = controllers.get(existing); + + if (controller == null) { + for (Map.Entry entry : controllers.entrySet()) { + if (entry.getKey().getBareJid() != null + && entry.getKey().getBareJid().equals(jid)) { + controller = entry.getValue(); + break; + } + } + } + if (controller != null) { + controller.markSecureInbound(); + controller.displayIncomingSecureMessage(body); + } else { + reportError("Aucun contrôleur Spark trouvé pour " + jid, + new IllegalStateException("controllers=" + controllers.size())); + } + } catch (Throwable error) { + reportError("Affichage du message OMEMO impossible", error); + } + }); + } + + @Override + public void onStatus(String status) { + LOG.info(status); + if (diagnostic != null) { + diagnostic.logRuntimeEvent("STATUS", status); + } + } + + public boolean isItemNotFound(Throwable error) { + Throwable current = error; + while (current != null) { + String text = String.valueOf(current.getMessage()).toLowerCase(); + if (text.contains("item-not-found") || text.contains("item_not_found")) { + return true; + } + current = current.getCause(); + } + + return false; + } + + public boolean isSimplePayloadCast(Throwable error) { + Throwable current = error; + while (current != null) { + String className = current.getClass().getName(); + String message = String.valueOf(current.getMessage()); + if ("java.lang.ClassCastException".equals(className) + && message.contains("SimplePayload") + && message.contains("OmemoDeviceListElement")) { + return true; + } + current = current.getCause(); + } + + return false; + } + + public String safeMessage(Throwable error) { + if (error == null) { + return "(erreur inconnue)"; + } + String message = error.getMessage(); + return message == null || message.trim().isEmpty() + ? error.getClass().getName() + : message; + } + + public void reportError(String context, Throwable error) { + LOG.log(Level.SEVERE, context, error); + if (diagnostic != null) { + diagnostic.logRuntimeError(context, error); + } + + File target = diagnosticFile(); + PrintWriter writer = null; + try { + writer = new PrintWriter(new FileWriter(target, true)); + writer.println("=================================================="); + writer.println(context); + writer.println("Java: " + System.getProperty("java.version")); + writer.println("JVM: " + System.getProperty("java.vm.name")); + writer.println("Error: " + error.getClass().getName()); + writer.println("Message: " + safeMessage(error)); + + error.printStackTrace(writer); + writer.flush(); + } catch (Exception logError) { + LOG.log(Level.SEVERE, "Impossible d'écrire le diagnostic OMEMO", logError); + } finally { + if (writer != null) { + writer.close(); + } + } + } + + + private File wireDiagnosticFile() { + File base = new File(System.getenv("APPDATA") != null + ? System.getenv("APPDATA") + : System.getProperty("user.home")); + + File directory = new File(new File(base, "Spark"), "logs"); + + return new File(directory, "avhiral-omemo-wire-0.13.log"); + } + + private synchronized void startWireDiagnosticSilently() { + try { + if (diagnostic != null && diagnostic.isRunning()) { + return; + } + AbstractXMPPConnection connection = SparkManager.getConnection(); + if (connection == null || !connection.isAuthenticated()) { + return; + } + diagnostic = new OmemoDiagnosticService(connection, wireDiagnosticFile()); + diagnostic.start(); + diagnostic.logRuntimeEvent("PLUGIN", "AVHIRAL Secure OMEMO 0.14.2 chargé."); + } catch (Throwable error) { + reportError("Démarrage diagnostic filaire impossible", error); + } + } + + private void startWireDiagnostic() { + startWireDiagnosticSilently(); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + diagnostic != null && diagnostic.isRunning() + ? "Capture filaire active.\n\n" + + wireDiagnosticFile().getAbsolutePath() + : "Capture non démarrée : Spark n'est peut-être " + + "pas encore authentifié.", + "Diagnostic OMEMO", + JOptionPane.INFORMATION_MESSAGE); + } + + private synchronized void stopWireDiagnostic() { + if (diagnostic != null) { + diagnostic.stop(); + } + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + "Capture filaire arrêtée.", + "Diagnostic OMEMO", + JOptionPane.INFORMATION_MESSAGE); + } + + private void openWireDiagnostic() { + try { + if (diagnostic == null) { + diagnostic = new OmemoDiagnosticService( + SparkManager.getConnection(), + wireDiagnosticFile()); + } + + diagnostic.openLog(); + } catch (Throwable error) { + reportError( + "Ouverture du journal filaire impossible", + error); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + safeMessage(error) + + "\n\nJournal :\n" + + wireDiagnosticFile().getAbsolutePath(), + "Diagnostic OMEMO", + JOptionPane.ERROR_MESSAGE); + } + } + + private void clearWireDiagnostic() { + try { + if (diagnostic == null) { + diagnostic = new OmemoDiagnosticService( + SparkManager.getConnection(), + wireDiagnosticFile()); + } + + diagnostic.clear(); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + "Journal filaire effacé.", + "Diagnostic OMEMO", + JOptionPane.INFORMATION_MESSAGE); + } catch (Throwable error) { + reportError( + "Effacement du journal filaire impossible", + error); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + safeMessage(error), + "Diagnostic OMEMO", + JOptionPane.ERROR_MESSAGE); + } + } + + private void showDiagnostics() { + String text = "Version : 0.14.2\n" + + "Java : " + + System.getProperty("java.version") + + '\n' + + "Spark connecté : " + + (SparkManager.getConnection() != null + && SparkManager.getConnection() + .isAuthenticated()) + + '\n' + + "Runtime OMEMO prêt : " + + (runtime != null && runtime.isReady()) + + '\n' + + "Conversations intégrées : " + + controllers.size() + + '\n' + + "Suppresseur fallback actif : " + + (fallbackSuppressor != null) + + '\n' + + "Capture filaire active : " + + (diagnostic != null && diagnostic.isRunning()) + + '\n' + + "Journal filaire : " + + wireDiagnosticFile().getAbsolutePath() + + '\n' + + "Rapport : " + + diagnosticFile().getAbsolutePath(); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + text, + "Diagnostic OMEMO", + JOptionPane.INFORMATION_MESSAGE); + } + + private void purgeOwnDevices() { + try { + ensureRuntime(); + runtime.purgeOwnDeviceList(); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + "La liste des appareils OMEMO a été republiée.", + "AVHIRAL OMEMO", + JOptionPane.INFORMATION_MESSAGE); + } catch (Throwable error) { + reportError( + "Republication des appareils impossible", + error); + + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + safeMessage(error), + "AVHIRAL OMEMO", + JOptionPane.ERROR_MESSAGE); + } + } + + private void showInstallationNoticeOnce() { + final File marker = + installationNoticeMarker(); + + if (marker.isFile()) { + return; + } + + File parent = marker.getParentFile(); + + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + + try { + FileWriter writer = + new FileWriter(marker); + + try { + writer.write( + "AVHIRAL Secure OMEMO 0.14.2 installed"); + writer.flush(); + } finally { + writer.close(); + } + } catch (Exception error) { + LOG.log( + Level.WARNING, + "Impossible d'enregistrer l'avis d'installation.", + error); + } + + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + JOptionPane.showMessageDialog( + SparkManager.getMainWindow(), + "AVHIRAL Secure OMEMO 0.14.2 installé.\n" + + "Le cadenas apparaît dans les conversations privées.", + "AVHIRAL Secure OMEMO", + JOptionPane.INFORMATION_MESSAGE); + } + }); + } + + private static File installationNoticeMarker() { + File base = new File( + System.getenv("APPDATA") != null + ? System.getenv("APPDATA") + : System.getProperty("user.home")); + + File directory = new File( + new File(base, "Spark"), + "avhiral-omemo"); + + return new File( + directory, + "installation-notice-0.14.2.flag"); + } + + private static File diagnosticFile() { + File base = new File( + System.getenv("APPDATA") != null + ? System.getenv("APPDATA") + : System.getProperty("user.home")); + + File directory = new File( + new File(base, "Spark"), + "logs"); + + if (!directory.exists()) { + directory.mkdirs(); + } + + return new File( + directory, + "avhiral-omemo-error.log"); + } + + @Override + public void shutdown() { + removeFallbackSuppressor(); + + ChatManager.getInstance() + .removeMessageFilter(this); + ChatManager.getInstance() + .removeTranscriptWindowInterceptor(this); + ChatManager.getInstance() + .removeChatRoomListener(this); + + for (IntegratedChatController controller + : controllers.values()) { + controller.dispose(); + } + + controllers.clear(); + + if (runtime != null) { + runtime.removeListener(this); + runtime.close(); + runtime = null; + } + + if (diagnostic != null) { + diagnostic.close(); + diagnostic = null; + } + + if (avhiralMenu != null) { + SparkManager.getMainWindow() + .getJMenuBar() + .remove(avhiralMenu); + + SparkManager.getMainWindow() + .getJMenuBar() + .revalidate(); + + SparkManager.getMainWindow() + .getJMenuBar() + .repaint(); + + avhiralMenu = null; + } + } + + @Override + public boolean canShutDown() { + return true; + } + + @Override + public void uninstall() { + shutdown(); + } + + @Override + public void filterOutgoing(ChatRoom room, MessageBuilder messageBuilder) { + // Les envois OMEMO sont gérés par OmemoRuntime. + } + + /** + * Filtre natif Spark. Il est exécuté dans ChatRoom.insertMessage() avant + * l'ajout à l'historique de la conversation. + */ + @Override + public void filterIncoming(ChatRoom room, MessageBuilder messageBuilder) { + if (!containsLegacyOmemo(messageBuilder)) { + return; + } + String body = messageBuilder.getBody(); + if (body != null) { + messageBuilder.setBody(null); + if (diagnostic != null) { + diagnostic.logRuntimeEvent( + "SPARK MESSAGE FILTER", + "room=" + room.getBareJid() + "\n" + + "stanzaId=" + messageBuilder.getStanzaId() + "\n" + + "removedBody=" + body); + } + } + } +} diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/IntegratedChatController.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/IntegratedChatController.java new file mode 100644 index 000000000..0094fa8eb --- /dev/null +++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/IntegratedChatController.java @@ -0,0 +1,404 @@ +package org.jivesoftware.spark.plugin.omemo; + +import java.awt.Color; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.Date; +import javax.swing.AbstractAction; +import javax.swing.Action; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JOptionPane; +import javax.swing.JToggleButton; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; + +import org.jivesoftware.spark.SparkManager; +import org.jivesoftware.spark.ui.ChatInputEditor; +import org.jivesoftware.spark.ui.MessageEventListener; +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smackx.carbons.packet.CarbonExtension; +import org.jivesoftware.smackx.omemo.element.OmemoElement; +import org.jivesoftware.smackx.omemo.element.OmemoElement_VAxolotl; +import org.jivesoftware.spark.ui.ChatRoom; +import org.jivesoftware.spark.ui.rooms.ChatRoomImpl; + +/** + * Intègre OMEMO directement dans une conversation privée Spark. + *

+ * Le bouton et la touche Entrée sont interceptés uniquement lorsque le + * cadenas OMEMO est actif. En mode désactivé, Spark conserve son comportement + * natif. + */ +public final class IntegratedChatController implements MessageEventListener { + private static final String ENTER_ACTION = "avhiral-omemo-send-enter"; + + private final ChatRoomImpl room; + private final AvhOmemoPlugin plugin; + private final ChatInputEditor editor; + private final JButton sendButton; + private final JToggleButton lockButton; + + private final ActionListener[] originalSendListeners; + private final Object originalEnterActionKey; + private final Action originalEnterAction; + + private volatile boolean enabled; + private volatile boolean operationInProgress; + + public IntegratedChatController( + ChatRoomImpl room, + AvhOmemoPlugin plugin) { + + this.room = room; + this.plugin = plugin; + this.editor = room.getChatInputEditor(); + this.sendButton = room.getSendButton(); + + this.originalSendListeners = sendButton.getActionListeners(); + + InputMap inputMap = editor.getInputMap(JComponent.WHEN_FOCUSED); + + ActionMap actionMap = editor.getActionMap(); + + KeyStroke enter = KeyStroke.getKeyStroke("ENTER"); + + this.originalEnterActionKey = inputMap.get(enter); + this.originalEnterAction = originalEnterActionKey == null ? null : actionMap.get(originalEnterActionKey); + + this.lockButton = createLockButton(); + + installSendInterception(); + installEnterInterception(); + room.addMessageEventListener(this); + + // addEditorComponent place le composant dans la barre inférieure + // de saisie, au même niveau que les autres outils de Spark. + room.addEditorComponent(lockButton); + updateVisualState(); + } + + private JToggleButton createLockButton() { + final JToggleButton button = new JToggleButton("🔓 OMEMO"); + button.setToolTipText("Activer le chiffrement OMEMO pour cette conversation"); + button.setFocusable(false); + button.addActionListener(event -> { + if (button.isSelected()) { + enableOmemo(); + } else { + enabled = false; + updateVisualState(); + } + }); + return button; + } + + private void installSendInterception() { + for (ActionListener listener : originalSendListeners) { + sendButton.removeActionListener(listener); + } + + sendButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + if (enabled) { + sendEncryptedFromEditor(); + } else { + invokeOriginalSend(event); + } + } + }); + } + + private void installEnterInterception() { + final InputMap inputMap = editor.getInputMap(JComponent.WHEN_FOCUSED); + final ActionMap actionMap = editor.getActionMap(); + final KeyStroke enter = KeyStroke.getKeyStroke("ENTER"); + + inputMap.put(enter, ENTER_ACTION); + + actionMap.put(ENTER_ACTION, new AbstractAction() { + private static final long serialVersionUID = 1L; + + @Override + public void actionPerformed(ActionEvent event) { + if (enabled) { + sendEncryptedFromEditor(); + } else if (originalEnterAction != null) { + originalEnterAction.actionPerformed(event); + } else { + invokeOriginalSend(event); + } + } + }); + } + + private void enableOmemo() { + operationInProgress = true; + updateVisualState(); + new SwingWorker() { + private Throwable failure; + + @Override + protected Void doInBackground() { + try { + /* + * Ne pas appeler requestDeviceListUpdateFor() ici. + * + * Sur Openfire, certains nœuds PEP peuvent être renvoyés + * sous forme de SimplePayload. Smack 4.4.6 tente alors de + * les convertir directement en OmemoDeviceListElement, + * ce qui provoque la ClassCastException observée. + * + * OmemoManager.encrypt() effectue lui-même la résolution + * des appareils au moment utile. L'activation du cadenas + * ne doit donc qu'initialiser le runtime local. + */ + plugin.ensureRuntime(); + enabled = true; + } catch (Throwable error) { + failure = error; + enabled = false; + } + return null; + } + + @Override + protected void done() { + operationInProgress = false; + updateVisualState(); + if (failure != null) { + plugin.reportError("Activation OMEMO impossible", failure); + JOptionPane.showMessageDialog(room, + "Activation OMEMO impossible.\n\n" + + plugin.safeMessage(failure) + + "\n\nAucun message en clair ne sera envoyé.", + "AVHIRAL OMEMO", + JOptionPane.ERROR_MESSAGE); + } else { + lockButton.setToolTipText( + "OMEMO actif. La liste des appareils sera " + + "résolue automatiquement lors de l'envoi."); + } + } + }.execute(); + } + + private void sendEncryptedFromEditor() { + if (operationInProgress) { + return; + } + final String body = editor.getText(); + if (body == null || body.trim().isEmpty()) { + return; + } + operationInProgress = true; + setInputEnabled(false); + updateVisualState(); + + new SwingWorker() { + private Throwable failure; + + @Override + protected Void doInBackground() { + try { + plugin.ensureRuntime(); + plugin.getRuntime().sendEncrypted(room.getBareJid(), body); + } catch (Throwable error) { + failure = error; + } + return null; + } + + @Override + protected void done() { + operationInProgress = false; + setInputEnabled(true); + if (failure == null) { + editor.setText(""); + room.addToTranscript("Moi 🔒", body, "#167D2D", new Date()); + room.scrollToBottom(); + lockButton.setToolTipText("Message envoyé avec chiffrement OMEMO."); + } else { + plugin.reportError("Envoi OMEMO impossible", failure); + String details = plugin.safeMessage(failure); + if (plugin.isSimplePayloadCast(failure)) { + details = + "Le service PEP a renvoyé une ancienne charge " + + "OMEMO non typée. Réessaie dans quelques " + + "secondes ou demande au correspondant de " + + "republier ses appareils OMEMO."; + } + + JOptionPane.showMessageDialog( + room, + "Envoi OMEMO impossible.\n\n" + + details + + "\n\nLe message n'a pas été envoyé en clair.", + "AVHIRAL OMEMO", + JOptionPane.ERROR_MESSAGE); + } + + updateVisualState(); + editor.requestFocusInWindow(); + } + }.execute(); + } + + private void invokeOriginalSend(ActionEvent event) { + for (ActionListener listener : originalSendListeners) { + listener.actionPerformed(event); + } + } + + private void setInputEnabled(boolean state) { + editor.setEnabled(state); + sendButton.setEnabled(state); + } + + private void updateVisualState() { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if (operationInProgress) { + lockButton.setText("⌛ OMEMO"); + lockButton.setForeground(new Color(185, 115, 0)); + return; + } + if (enabled) { + lockButton.setSelected(true); + lockButton.setText("🔒 OMEMO"); + lockButton.setForeground(new Color(20, 125, 45)); + lockButton.setToolTipText("OMEMO actif — aucun repli en clair."); + } else { + lockButton.setSelected(false); + lockButton.setText("🔓 OMEMO"); + lockButton.setForeground(Color.DARK_GRAY); + lockButton.setToolTipText("Clique pour activer OMEMO."); + } + } + }); + } + + + /** + * Spark affiche normalement le corps de repli OMEMO envoyé par Monal + * (par exemple "This message is OMEMO encrypted"). Le listener Spark est + * appelé avant l'insertion dans le transcript : on neutralise uniquement + * ce corps de repli lorsque la stanza contient réellement une extension + * OMEMO. Le texte déchiffré est ensuite inséré par OmemoRuntime.Listener. + */ + @Override + public void receivingMessage(Message message) { + if (message == null) { + return; + } + + if (containsOmemoPayload(message)) { + enabled = true; + updateVisualState(); + } + + CarbonExtension carbon = CarbonExtension.from(message); + if (carbon != null) { + if (carbon.getForwarded() != null && carbon.getForwarded().getForwardedStanza() instanceof Message) { + Message forwarded = carbon.getForwarded().getForwardedStanza(); + if (containsOmemoPayload(forwarded)) { + enabled = true; + updateVisualState(); + } + } + } + } + + @Override + public void sendingMessage(Message message) { + // Les envois OMEMO sont effectués directement par OmemoRuntime. + } + + private boolean containsOmemoPayload(Message message) { + if (message == null) { + return false; + } + if (message.hasExtension(OmemoElement.NAME_ENCRYPTED, OmemoElement_VAxolotl.NAMESPACE)) { + return true; + } + + /* + * Smack 4.4.6 sait déchiffrer le namespace legacy Axolotl. + * Ne pas masquer un éventuel corps de repli OMEMO 2 tant qu'aucun + * moteur OMEMO 2 n'est présent : cela éviterait de perdre un message. + */ + return false; + } + + public void markSecureInbound() { + enabled = true; + updateVisualState(); + } + + public ChatRoom getRoom() { + return room; + } + + public boolean isEnabled() { + return enabled; + } + + public void displayIncomingSecureMessage(final String body) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + Message decrypted = new Message(); + decrypted.setFrom(room.getBareJid()); + decrypted.setTo(SparkManager.getSessionManager().getUserBareAddress()); + decrypted.setType(Message.Type.chat); + decrypted.setBody(body); + + /* + * addToTranscript(String,...) ne dessine rien à l'écran dans + * Spark 3.0.2 : cette méthode ne fait qu'alimenter la liste + * persistée. L'affichage réel doit passer par + * TranscriptWindow.insertMessage(). + */ + room.getTranscriptWindow().insertMessage( + room.getRoomTitle() + " 🔒", + decrypted, + new Color(20, 125, 45)); + + room.addToTranscript(decrypted, true); + + room.getTranscriptWindow().validate(); + room.getTranscriptWindow().repaint(); + room.scrollToBottom(); + } + }); + } + + public void dispose() { + room.removeMessageEventListener(this); + sendButton.removeActionListener(sendButton.getActionListeners()[sendButton.getActionListeners().length - 1]); + + for (ActionListener listener : originalSendListeners) { + sendButton.addActionListener(listener); + } + + InputMap inputMap = editor.getInputMap(JComponent.WHEN_FOCUSED); + ActionMap actionMap = editor.getActionMap(); + KeyStroke enter = KeyStroke.getKeyStroke("ENTER"); + + if (originalEnterActionKey != null) { + inputMap.put(enter, originalEnterActionKey); + if (originalEnterAction != null) { + actionMap.put(originalEnterActionKey, originalEnterAction); + } + } else { + inputMap.remove(enter); + } + room.removeEditorComponent(lockButton); + } +} diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoDiagnosticService.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoDiagnosticService.java new file mode 100644 index 000000000..9081bab56 --- /dev/null +++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoDiagnosticService.java @@ -0,0 +1,387 @@ +package org.jivesoftware.spark.plugin.omemo; + +import java.awt.Desktop; +import java.io.File; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import org.jivesoftware.smack.AbstractXMPPConnection; +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.filter.StanzaTypeFilter; +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smack.provider.ProviderManager; + +/** + * Capture passive des stanzas XMPP pour diagnostiquer l'interopérabilité + * OMEMO entre Spark/Smack 4.4.6 et les clients distants. + *

+ * Cette classe ne modifie aucune stanza et ne journalise pas les clés privées. + */ +public final class OmemoDiagnosticService implements AutoCloseable { + private static final String LEGACY_NAMESPACE = "eu.siacs.conversations.axolotl"; + private static final String OMEMO2_NAMESPACE = "urn:xmpp:omemo:2"; + + private final Object fileLock = new Object(); + private final AbstractXMPPConnection connection; + private final File logFile; + + private StanzaListener incomingListener; + private StanzaListener outgoingListener; + private volatile boolean running; + + public OmemoDiagnosticService( + AbstractXMPPConnection connection, + File logFile) { + + this.connection = connection; + this.logFile = logFile; + } + + public synchronized void start() { + if (running) { + return; + } + + if (connection == null || !connection.isAuthenticated()) { + throw new IllegalStateException("Connexion XMPP non authentifiée."); + } + + incomingListener = new StanzaListener() { + @Override + public void processStanza(Stanza stanza) { + logMessage("IN", stanza); + } + }; + + outgoingListener = new StanzaListener() { + @Override + public void processStanza(Stanza stanza) { + logMessage("OUT", stanza); + } + }; + + connection.addAsyncStanzaListener( + incomingListener, + StanzaTypeFilter.MESSAGE); + + connection.addStanzaSendingListener( + outgoingListener, + StanzaTypeFilter.MESSAGE); + + running = true; + + appendSection( + "DIAGNOSTIC START", + "localJid=" + safe(connection.getUser()) + "\n" + + "connectionClass=" + + connection.getClass().getName() + "\n" + + providerStatus()); + } + + public synchronized void stop() { + if (!running) { + return; + } + + if (incomingListener != null) { + connection.removeAsyncStanzaListener(incomingListener); + } + + if (outgoingListener != null) { + connection.removeStanzaSendingListener(outgoingListener); + } + + incomingListener = null; + outgoingListener = null; + running = false; + + appendSection("DIAGNOSTIC STOP", "Capture arrêtée."); + } + + public boolean isRunning() { + return running; + } + + public File getLogFile() { + return logFile; + } + + public void clear() throws Exception { + synchronized (fileLock) { + if (logFile.exists() && !logFile.delete()) { + throw new IllegalStateException("Impossible d'effacer : " + logFile.getAbsolutePath()); + } + } + } + + public void openLog() throws Exception { + ensureParent(); + + if (!logFile.exists()) { + appendSection( + "DIAGNOSTIC", + "Journal créé manuellement."); + } + + if (!Desktop.isDesktopSupported()) { + throw new IllegalStateException( + "Desktop.open n'est pas disponible. Journal : " + + logFile.getAbsolutePath()); + } + + Desktop.getDesktop().open(logFile); + } + + public void logRuntimeEvent(String category, String details) { + appendSection("RUNTIME " + category, details == null ? "(null)" : details); + } + + public void logRuntimeError(String category, Throwable error) { + StringBuilder report = new StringBuilder(); + Throwable current = error; + int depth = 0; + + while (current != null && depth < 16) { + report.append("cause[").append(depth).append("].class=") + .append(current.getClass().getName()) + .append('\n'); + report.append("cause[").append(depth).append("].message=") + .append(safe(current.getMessage())) + .append('\n'); + + current = current.getCause(); + depth++; + } + + appendSection( + "RUNTIME ERROR " + category, + report.toString()); + } + + private void logMessage( + String direction, + Stanza stanza) { + + if (!(stanza instanceof Message)) { + return; + } + + Message message = (Message) stanza; + StringBuilder report = new StringBuilder(); + + report.append("direction=").append(direction).append('\n'); + report.append("messageClass=") + .append(message.getClass().getName()) + .append('\n'); + report.append("from=").append(safe(message.getFrom())).append('\n'); + report.append("to=").append(safe(message.getTo())).append('\n'); + report.append("type=").append(safe(message.getType())).append('\n'); + report.append("stanzaId=") + .append(safe(message.getStanzaId())) + .append('\n'); + report.append("body=").append(safe(message.getBody())).append('\n'); + + boolean legacyEncrypted = false; + boolean omemo2Encrypted = false; + boolean carbon = false; + int extensionIndex = 0; + + for (var extension : message.getExtensions()) { + extensionIndex++; + + String element = safe(extension.getElementName()); + String namespace = safe(extension.getNamespace()); + + report.append("extension[") + .append(extensionIndex) + .append("].class=") + .append(extension.getClass().getName()) + .append('\n'); + + report.append("extension[") + .append(extensionIndex) + .append("].element=") + .append(element) + .append('\n'); + + report.append("extension[") + .append(extensionIndex) + .append("].namespace=") + .append(namespace) + .append('\n'); + + if ("encrypted".equals(element) + && LEGACY_NAMESPACE.equals(namespace)) { + legacyEncrypted = true; + } + + if ("encrypted".equals(element) + && OMEMO2_NAMESPACE.equals(namespace)) { + omemo2Encrypted = true; + } + + if ("sent".equals(element) + || "received".equals(element)) { + if ("urn:xmpp:carbons:2".equals(namespace)) { + carbon = true; + } + } + } + + report.append("detected.legacyAxolotl=") + .append(legacyEncrypted) + .append('\n'); + report.append("detected.omemo2=") + .append(omemo2Encrypted) + .append('\n'); + report.append("detected.carbon=") + .append(carbon) + .append('\n'); + + report.append("provider.legacy.encrypted=") + .append(providerClass( + "encrypted", + LEGACY_NAMESPACE)) + .append('\n'); + report.append("provider.legacy.list=") + .append(providerClass( + "list", + LEGACY_NAMESPACE)) + .append('\n'); + report.append("provider.legacy.bundle=") + .append(providerClass( + "bundle", + LEGACY_NAMESPACE)) + .append('\n'); + report.append("provider.omemo2.encrypted=") + .append(providerClass( + "encrypted", + OMEMO2_NAMESPACE)) + .append('\n'); + + report.append("xml=") + .append(safeXml(message)) + .append('\n'); + + appendSection( + "XMPP MESSAGE " + direction, + report.toString()); + } + + private String providerStatus() { + StringBuilder report = new StringBuilder(); + + report.append("provider.legacy.encrypted=") + .append(providerClass( + "encrypted", + LEGACY_NAMESPACE)) + .append('\n'); + report.append("provider.legacy.list=") + .append(providerClass( + "list", + LEGACY_NAMESPACE)) + .append('\n'); + report.append("provider.legacy.bundle=") + .append(providerClass( + "bundle", + LEGACY_NAMESPACE)) + .append('\n'); + report.append("provider.omemo2.encrypted=") + .append(providerClass( + "encrypted", + OMEMO2_NAMESPACE)) + .append('\n'); + + return report.toString(); + } + + private static String providerClass( + String element, + String namespace) { + + Object provider = + ProviderManager.getExtensionProvider( + element, + namespace); + + return provider == null + ? "(none)" + : provider.getClass().getName(); + } + + private static String safeXml(Stanza stanza) { + try { + CharSequence xml = stanza.toXML((String) null); + return xml == null + ? "(null)" + : xml.toString(); + } catch (Throwable error) { + return "(XML impossible: " + + error.getClass().getName() + + ": " + + safe(error.getMessage()) + + ")"; + } + } + + private void appendSection( + String title, + String content) { + + synchronized (fileLock) { + PrintWriter writer = null; + + try { + ensureParent(); + + writer = new PrintWriter( + new FileWriter(logFile, true)); + + writer.println( + "============================================================"); + writer.println( + new SimpleDateFormat( + "yyyy-MM-dd HH:mm:ss.SSS", + Locale.ROOT).format(new Date())); + writer.println(title); + writer.print(content); + + if (!content.endsWith("\n")) { + writer.println(); + } + + writer.flush(); + } catch (Exception ignored) { + // Le diagnostic ne doit jamais bloquer Spark. + } finally { + if (writer != null) { + writer.close(); + } + } + } + } + + private void ensureParent() { + File parent = logFile.getParentFile(); + + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + } + + private static String safe(Object value) { + return value == null + ? "(null)" + : String.valueOf(value); + } + + @Override + public void close() { + stop(); + } +} diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java new file mode 100644 index 000000000..48e32e879 --- /dev/null +++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java @@ -0,0 +1,69 @@ +package org.jivesoftware.spark.plugin.omemo; + +import java.util.logging.Logger; + +import org.jivesoftware.smack.StanzaListener; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smackx.omemo.element.OmemoElement; +import org.jivesoftware.smackx.omemo.element.OmemoElement_VAxolotl; + +/** + * Supprime le corps de compatibilité OMEMO avant que Spark ne l'insère dans le + * transcript. + *

+ * Le moteur OMEMO de Smack déchiffre ensuite la charge + * OmemoElement_VAxolotl et AvhOmemoPlugin.onSecureMessage() affiche le texte + * clair. + *

+ * Ce listener doit être enregistré avec addSyncStanzaListener(), pas avec un + * MessageEventListener Spark : ce dernier intervient trop tard, après + * l'affichage du fallback. + */ +public final class OmemoFallbackSuppressor implements StanzaListener { + private static final Logger LOG = + Logger.getLogger(OmemoFallbackSuppressor.class.getName()); + + public interface Listener { + void onFallbackSuppressed( + String from, + String stanzaId, + String originalBody); + } + + private final Listener listener; + + public OmemoFallbackSuppressor(Listener listener) { + this.listener = listener; + } + + @Override + public void processStanza(Stanza stanza) { + if (!(stanza instanceof Message)) { + return; + } + Message message = (Message) stanza; + if (!containsLegacyOmemo(message)) { + return; + } + String body = message.getBody(); + if (body == null) { + return; + } + /* + * On ne supprime le corps que si une vraie charge OMEMO legacy est + * présente. Le texte chiffré reste dans l'extension et + * sera déchiffré par OmemoManager. + */ + message.setBody(null); + String from = message.getFrom() == null ? "(inconnu)" : message.getFrom().toString(); + LOG.fine("Fallback OMEMO supprimé avant affichage : " + from + " stanza=" + message.getStanzaId()); + if (listener != null) { + listener.onFallbackSuppressed(from, message.getStanzaId(), body); + } + } + + private static boolean containsLegacyOmemo(Message message) { + return message.hasExtension(OmemoElement.NAME_ENCRYPTED, OmemoElement_VAxolotl.NAMESPACE); + } +} diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoRuntime.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoRuntime.java new file mode 100644 index 000000000..f599f9c3d --- /dev/null +++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoRuntime.java @@ -0,0 +1,213 @@ +package org.jivesoftware.spark.plugin.omemo; + +import java.io.File; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.jivesoftware.smack.AbstractXMPPConnection; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.packet.Stanza; +import org.jivesoftware.smackx.carbons.packet.CarbonExtension; +import org.jivesoftware.smackx.omemo.OmemoConfiguration; +import org.jivesoftware.smackx.omemo.OmemoManager; +import org.jivesoftware.smackx.omemo.OmemoMessage; +import org.jivesoftware.smackx.omemo.OmemoService; +import org.jivesoftware.smackx.omemo.listener.OmemoMessageListener; +import org.jivesoftware.smackx.omemo.signal.SignalFileBasedOmemoStore; +import org.jivesoftware.smackx.omemo.signal.SignalOmemoService; +import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.impl.JidCreate; + +public final class OmemoRuntime implements AutoCloseable { + private static final Logger LOG = + Logger.getLogger(OmemoRuntime.class.getName()); + + public interface Listener { + void onSecureMessage(String from, String body); + + void onStatus(String status); + } + + private final AbstractXMPPConnection connection; + private final File storePath; + private final List listeners = new CopyOnWriteArrayList(); + + private OmemoManager manager; + private OmemoMessageListener omemoListener; + + public OmemoRuntime(AbstractXMPPConnection connection, File storePath) { + this.connection = connection; + this.storePath = storePath; + } + + public synchronized void initialize() throws Exception { + if (manager != null) { + return; + } + if (connection == null || !connection.isAuthenticated()) { + throw new IllegalStateException("Spark doit être connecté avant l'initialisation OMEMO."); + } + if (!storePath.exists() && !storePath.mkdirs()) { + throw new IllegalStateException("Impossible de créer le stockage OMEMO : " + storePath); + } + SignalOmemoService.acknowledgeLicense(); + if (!SignalOmemoService.isServiceRegistered()) { + SignalOmemoService.setup(); + } + try { + @SuppressWarnings({"rawtypes", "unchecked"}) + OmemoService service = OmemoService.getInstance(); + service.setOmemoStoreBackend(new SignalFileBasedOmemoStore(storePath)); + } catch (IllegalStateException alreadyConfigured) { + LOG.log(Level.FINE, "Le backend OMEMO était déjà configuré.", alreadyConfigured); + } + + OmemoConfiguration.setAddOmemoHintBody(false); + OmemoConfiguration.setCompleteSessionWithEmptyMessage(false); + OmemoConfiguration.setDeleteStaleDevices(true); + manager = OmemoManager.getInstanceFor(connection); + File trustFile = new File(storePath, "trust-decisions.properties"); + + manager.setTrustCallback(new PersistentTrustCallback(trustFile)); + + omemoListener = new OmemoMessageListener() { + @Override + public void onOmemoMessageReceived(Stanza stanza, OmemoMessage.Received decryptedMessage) { + if (decryptedMessage == null || decryptedMessage.isKeyTransportMessage()) { + return; + } + String from = stanza.getFrom() == null ? "inconnu" : stanza.getFrom().asBareJid().toString(); + emitStatus("Callback onOmemoMessageReceived — from=" + from + ", keyTransport=" + decryptedMessage.isKeyTransportMessage()); + + notifySecureMessage(from, decryptedMessage.getBody()); + } + + @Override + public void onOmemoCarbonCopyReceived( + CarbonExtension.Direction direction, + Message carbonCopy, + Message wrappingMessage, + OmemoMessage.Received decryptedCarbonCopy) { + + if (decryptedCarbonCopy == null + || decryptedCarbonCopy.isKeyTransportMessage()) { + return; + } + + Message source = carbonCopy != null + ? carbonCopy + : wrappingMessage; + + String from = source == null || source.getFrom() == null + ? "copie-carbone" + : source.getFrom().asBareJid().toString(); + + emitStatus( + "Callback onOmemoCarbonCopyReceived — direction=" + + direction + + ", from=" + + from); + + notifySecureMessage(from, decryptedCarbonCopy.getBody()); + } + }; + + manager.addOmemoMessageListener(omemoListener); + + emitStatus("Avant manager.initialize() — manager=" + manager.getClass().getName()); + manager.initialize(); + emitStatus("Après manager.initialize() — appareil " + manager.getDeviceId()); + } + + public void addListener(Listener listener) { + if (listener != null) { + listeners.add(listener); + } + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + public boolean isReady() { + return connection != null && connection.isAuthenticated() && manager != null; + } + + public int getDeviceId() { + Integer id = manager == null ? null : manager.getDeviceId(); + return id == null ? -1 : id; + } + + public void sendEncrypted(BareJid recipient, String body) throws Exception { + if (!isReady()) { + throw new IllegalStateException("Connexion XMPP/OMEMO indisponible."); + } + if (recipient == null) { + throw new IllegalArgumentException("Le JID du destinataire est vide."); + } + if (body == null || body.trim().isEmpty()) { + throw new IllegalArgumentException("Le message est vide."); + } + emitStatus("Avant encrypt — recipient=" + recipient + ", bodyLength=" + body.length()); + OmemoMessage.Sent encrypted = manager.encrypt(recipient, body); + emitStatus("Après encrypt — recipient=" + recipient); + Message stanza = encrypted.buildMessage(connection.getStanzaFactory().buildMessageStanza(), recipient); + connection.sendStanza(stanza); + } + + public void sendEncrypted(String recipient, String body) throws Exception { + sendEncrypted(JidCreate.bareFrom(recipient.trim()), body); + } + + /** + * Rafraîchissement explicite réservé aux opérations de maintenance. + *

+ * Ne pas appeler cette méthode lors de l'activation du cadenas : certains + * serveurs Openfire renvoient alors un SimplePayload que Smack 4.4.6 ne + * sait pas convertir directement en OmemoDeviceListElement. + */ + public void requestDeviceListUpdate(BareJid recipient) throws Exception { + if (!isReady()) { + throw new IllegalStateException( + "OMEMO non initialisé."); + } + manager.requestDeviceListUpdateFor(recipient); + } + + public void purgeOwnDeviceList() throws Exception { + if (!isReady()) { + throw new IllegalStateException( + "OMEMO non initialisé."); + } + + manager.purgeDeviceList(); + emitStatus( + "Liste locale des appareils OMEMO republiée."); + } + + private void notifySecureMessage(String from, String body) { + for (Listener listener : listeners) { + listener.onSecureMessage(from, body == null ? "" : body); + } + } + + private void emitStatus(String status) { + LOG.info(status); + for (Listener listener : listeners) { + listener.onStatus(status); + } + } + + @Override + public synchronized void close() { + if (manager != null && omemoListener != null) { + manager.removeOmemoMessageListener(omemoListener); + } + + omemoListener = null; + manager = null; + listeners.clear(); + } +} diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java new file mode 100644 index 000000000..9112f02ec --- /dev/null +++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java @@ -0,0 +1,101 @@ +package org.jivesoftware.spark.plugin.omemo; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.jivesoftware.smackx.omemo.internal.OmemoDevice; +import org.jivesoftware.smackx.omemo.trust.OmemoTrustCallback; +import org.jivesoftware.smackx.omemo.trust.TrustState; +import org.jivesoftware.smackx.omemo.trust.OmemoFingerprint; + +/** + * Persistance locale des décisions de confiance OMEMO. + *

+ * Politique Alpha 0.11 : TOFU (Trust On First Use). + * Une nouvelle empreinte est acceptée lors de sa première observation, puis + * mémorisée. Une modification ultérieure produit une nouvelle entrée et doit + * être vérifiée via l'interface de gestion des empreintes d'une version future. + */ +public final class PersistentTrustCallback implements OmemoTrustCallback { + private static final Logger LOG = Logger.getLogger(PersistentTrustCallback.class.getName()); + + private final File trustFile; + private final Properties states = new Properties(); + + public PersistentTrustCallback(File trustFile) throws IOException { + this.trustFile = trustFile; + File parent = trustFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Impossible de créer le dossier de confiance : " + parent); + } + load(); + } + + @Override + public synchronized TrustState getTrust(OmemoDevice device, OmemoFingerprint fingerprint) { + String key = key(device, fingerprint); + String stored = states.getProperty(key); + if (stored != null) { + try { + return TrustState.valueOf(stored); + } catch (IllegalArgumentException invalidState) { + LOG.log(Level.WARNING, "État de confiance invalide pour " + key, invalidState); + } + } + + // TOFU : première empreinte observée acceptée puis figée localement. + states.setProperty(key, TrustState.trusted.name()); + try { + save(); + } catch (IOException error) { + LOG.log(Level.WARNING, "Impossible d'enregistrer la décision TOFU.", error); + } + + return TrustState.trusted; + } + + @Override + public synchronized void setTrust(OmemoDevice device, OmemoFingerprint fingerprint, TrustState state) { + if (state == null) { + state = TrustState.undecided; + } + states.setProperty(key(device, fingerprint), state.name()); + try { + save(); + } catch (IOException error) { + LOG.log(Level.WARNING, "Impossible d'enregistrer la décision de confiance.", error); + } + } + + private static String key(OmemoDevice device, OmemoFingerprint fingerprint) { + return device.toString() + "|" + fingerprint.toString().toLowerCase(); + } + + private void load() throws IOException { + if (!trustFile.isFile()) { + return; + } + try (FileInputStream input = new FileInputStream(trustFile)) { + states.load(input); + } + } + + private void save() throws IOException { + File temporary = new File(trustFile.getParentFile(), trustFile.getName() + ".tmp"); + try (FileOutputStream output = new FileOutputStream(temporary)) { + states.store(output, "AVHIRAL OMEMO trust decisions - local TOFU store"); + output.getFD().sync(); + } + if (trustFile.exists() && !trustFile.delete()) { + throw new IOException("Impossible de remplacer le fichier de confiance."); + } + if (!temporary.renameTo(trustFile)) { + throw new IOException("Impossible d'activer le nouveau fichier de confiance."); + } + } +} diff --git a/plugins/omemo/src/main/plugin/plugin.xml b/plugins/omemo/src/main/plugin/plugin.xml new file mode 100644 index 000000000..9ef191ec4 --- /dev/null +++ b/plugins/omemo/src/main/plugin/plugin.xml @@ -0,0 +1,11 @@ + + + ${project.name} + ${project.version} + ${project.description} + AVHIRAL + 3.1.0 + 11 + Windows,Linux,Mac + org.jivesoftware.spark.plugin.omemo.AvhOmemoPlugin + diff --git a/pom.xml b/pom.xml index 4a625587d..524db8989 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,7 @@ plugins/meet + plugins/omemo plugins/otr plugins/reversi plugins/roar