mirror of
https://github.com/igniterealtime/Spark.git
synced 2026-08-18 11:10:02 +00:00
OMEMO
This commit is contained in:
committed by
Sergey Ponomarev
parent
9ab30f6755
commit
210eaf1342
85
plugins/omemo/README.md
Normal file
85
plugins/omemo/README.md
Normal 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
19
plugins/omemo/SECURITY.md
Normal 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
50
plugins/omemo/pom.xml
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
plugins/omemo/src/main/plugin/plugin.xml
Normal file
11
plugins/omemo/src/main/plugin/plugin.xml
Normal 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>
|
||||
Reference in New Issue
Block a user