This commit is contained in:
AVHIRAL
2026-07-13 21:53:41 +03:00
committed by Sergey Ponomarev
parent 9ab30f6755
commit 210eaf1342
11 changed files with 2110 additions and 0 deletions

85
plugins/omemo/README.md Normal file
View File

@ -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.

19
plugins/omemo/SECURITY.md Normal file
View File

@ -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.

50
plugins/omemo/pom.xml Normal file
View File

@ -0,0 +1,50 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.igniterealtime.spark.plugins</groupId>
<artifactId>plugin</artifactId>
<version>3.1.0-SNAPSHOT</version>
<relativePath>../plugin/pom.xml</relativePath>
</parent>
<artifactId>omemo</artifactId>
<version>0.14.2</version>
<name>Spark OMEMO</name>
<description>OMEMO message encryption.</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<contributors>
<contributor>
<name>AVHIRAL</name>
<roles>
<role>Author</role>
</roles>
</contributor>
</contributors>
<dependencies>
<dependency>
<groupId>org.igniterealtime.smack</groupId>
<artifactId>smack-omemo</artifactId>
<version>${dependency.smack.version}</version>
</dependency>
<dependency>
<groupId>org.igniterealtime.smack</groupId>
<artifactId>smack-omemo-signal</artifactId>
<version>${dependency.smack.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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<ChatRoom, IntegratedChatController> controllers =
new HashMap<ChatRoom, IntegratedChatController>();
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é.
* <p>
* 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<ChatRoom, IntegratedChatController> 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);
}
}
}
}

View File

@ -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.
* <p>
* 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<Void, Void>() {
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<Void, Void>() {
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);
}
}

View File

@ -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.
* <p>
* 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();
}
}

View File

@ -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.
* <p>
* Le moteur OMEMO de Smack déchiffre ensuite la charge
* OmemoElement_VAxolotl et AvhOmemoPlugin.onSecureMessage() affiche le texte
* clair.
* <p>
* 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 <encrypted/> 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);
}
}

View File

@ -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<Listener> listeners = new CopyOnWriteArrayList<Listener>();
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.
* <p>
* 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();
}
}

View File

@ -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.
* <p>
* 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.");
}
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<plugin>
<name>${project.name}</name>
<version>${project.version}</version>
<description>${project.description}</description>
<author>AVHIRAL</author>
<minSparkVersion>3.1.0</minSparkVersion>
<java>11</java>
<os>Windows,Linux,Mac</os>
<class>org.jivesoftware.spark.plugin.omemo.AvhOmemoPlugin</class>
</plugin>

View File

@ -66,6 +66,7 @@
<!-- <module>plugins/phone</module>-->
<!-- <module>plugins/jingle</module>-->
<module>plugins/meet</module>
<module>plugins/omemo</module>
<module>plugins/otr</module>
<module>plugins/reversi</module>
<module>plugins/roar</module>