diff --git a/build.gradle b/build.gradle index 4b858ae38..b6ce8ff67 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ configurations { } dependencies { + implementation 'org.jitsi:org.otr4j:0.23' implementation 'com.github.webrtc-sdk:android:93.4577.01' implementation project(':libs:android-transcoder') playstoreImplementation('com.google.firebase:firebase-messaging:22.0.0') { diff --git a/src/git/java/eu/siacs/conversations/services/EmojiService.java b/src/git/java/eu/siacs/conversations/services/EmojiService.java deleted file mode 100644 index e12a0d1a0..000000000 --- a/src/git/java/eu/siacs/conversations/services/EmojiService.java +++ /dev/null @@ -1,26 +0,0 @@ -package eu.siacs.conversations.services; - -import android.content.Context; -import android.os.Build; - -import androidx.emoji.bundled.BundledEmojiCompatConfig; -import androidx.emoji.text.EmojiCompat; - -public class EmojiService { - - private final Context context; - - public EmojiService(Context context) { - this.context = context; - } - - public void init(boolean useBundledEmoji) { - BundledEmojiCompatConfig config = new BundledEmojiCompatConfig(context); - //On recent Androids we assume to have the latest emojis - //there are some annoying bugs with emoji compat that make it a safer choice not to use it when possible - // a) the text preview has annoying glitches when the cut of text contains emojis (the emoji will be half visible) - // b) can trigger a hardware rendering bug https://issuetracker.google.com/issues/67102093 - config.setReplaceAll(useBundledEmoji && Build.VERSION.SDK_INT < Build.VERSION_CODES.O); - EmojiCompat.init(config); - } -} \ No newline at end of file diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 43111b4e2..54a3c6d36 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -284,6 +284,10 @@ <activity android:name=".ui.PublishGroupChatProfilePictureActivity" android:label="@string/group_chat_avatar" /> + <activity + android:name=".ui.VerifyOTRActivity" + android:label="@string/verify_otr" + android:windowSoftInputMode="stateHidden" /> <activity android:name=".ui.ShareWithActivity" android:label="@string/app_name" diff --git a/src/main/java/eu/siacs/conversations/crypto/OtrService.java b/src/main/java/eu/siacs/conversations/crypto/OtrService.java new file mode 100644 index 000000000..61b86d259 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/crypto/OtrService.java @@ -0,0 +1,312 @@ +package eu.siacs.conversations.crypto; + +import android.util.Log; + +import net.java.otr4j.OtrEngineHost; +import net.java.otr4j.OtrException; +import net.java.otr4j.OtrPolicy; +import net.java.otr4j.OtrPolicyImpl; +import net.java.otr4j.crypto.OtrCryptoEngineImpl; +import net.java.otr4j.crypto.OtrCryptoException; +import net.java.otr4j.session.FragmenterInstructions; +import net.java.otr4j.session.InstanceTag; +import net.java.otr4j.session.SessionID; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.DSAPrivateKeySpec; +import java.security.spec.DSAPublicKeySpec; +import java.security.spec.InvalidKeySpecException; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.generator.MessageGenerator; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.chatstate.ChatState; +import eu.siacs.conversations.xmpp.jid.OtrJidHelper; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; + +public class OtrService extends OtrCryptoEngineImpl implements OtrEngineHost { + + private Account account; + private OtrPolicy otrPolicy; + private KeyPair keyPair; + private XmppConnectionService mXmppConnectionService; + + public OtrService(XmppConnectionService service, Account account) { + this.account = account; + this.otrPolicy = new OtrPolicyImpl(); + this.otrPolicy.setAllowV1(false); + this.otrPolicy.setAllowV2(true); + this.otrPolicy.setAllowV3(true); + this.keyPair = loadKey(account.getKeys()); + this.mXmppConnectionService = service; + } + + private KeyPair loadKey(final JSONObject keys) { + if (keys == null) { + return null; + } + synchronized (keys) { + try { + BigInteger x = new BigInteger(keys.getString("otr_x"), 16); + BigInteger y = new BigInteger(keys.getString("otr_y"), 16); + BigInteger p = new BigInteger(keys.getString("otr_p"), 16); + BigInteger q = new BigInteger(keys.getString("otr_q"), 16); + BigInteger g = new BigInteger(keys.getString("otr_g"), 16); + KeyFactory keyFactory = KeyFactory.getInstance("DSA"); + DSAPublicKeySpec pubKeySpec = new DSAPublicKeySpec(y, p, q, g); + DSAPrivateKeySpec privateKeySpec = new DSAPrivateKeySpec(x, p, q, g); + PublicKey publicKey = keyFactory.generatePublic(pubKeySpec); + PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec); + return new KeyPair(publicKey, privateKey); + } catch (JSONException e) { + return null; + } catch (NoSuchAlgorithmException e) { + return null; + } catch (InvalidKeySpecException e) { + return null; + } + } + } + + private void saveKey() { + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + KeyFactory keyFactory; + try { + keyFactory = KeyFactory.getInstance("DSA"); + DSAPrivateKeySpec privateKeySpec = keyFactory.getKeySpec( + privateKey, DSAPrivateKeySpec.class); + DSAPublicKeySpec publicKeySpec = keyFactory.getKeySpec(publicKey, + DSAPublicKeySpec.class); + this.account.setKey("otr_x", privateKeySpec.getX().toString(16)); + this.account.setKey("otr_g", privateKeySpec.getG().toString(16)); + this.account.setKey("otr_p", privateKeySpec.getP().toString(16)); + this.account.setKey("otr_q", privateKeySpec.getQ().toString(16)); + this.account.setKey("otr_y", publicKeySpec.getY().toString(16)); + } catch (final NoSuchAlgorithmException e) { + e.printStackTrace(); + } catch (final InvalidKeySpecException e) { + e.printStackTrace(); + } + + } + + @Override + public void askForSecret(SessionID id, InstanceTag instanceTag, String question) { + try { + final Jid jid = OtrJidHelper.fromSessionID(id); + Conversation conversation = this.mXmppConnectionService.find(this.account, jid); + if (conversation != null) { + conversation.smp().hint = question; + conversation.smp().status = Conversation.Smp.STATUS_CONTACT_REQUESTED; + mXmppConnectionService.updateConversationUi(); + } + } catch (IllegalArgumentException e) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": smp in invalid session " + id.toString()); + } + } + + @Override + public void finishedSessionMessage(SessionID arg0, String arg1) + throws OtrException { + + } + + @Override + public String getFallbackMessage(SessionID arg0) { + return MessageGenerator.OTR_FALLBACK_MESSAGE; + } + + @Override + public byte[] getLocalFingerprintRaw(SessionID arg0) { + try { + return getFingerprintRaw(getPublicKey()); + } catch (OtrCryptoException e) { + return null; + } + } + + public PublicKey getPublicKey() { + if (this.keyPair == null) { + return null; + } + return this.keyPair.getPublic(); + } + + @Override + public KeyPair getLocalKeyPair(SessionID arg0) throws OtrException { + if (this.keyPair == null) { + KeyPairGenerator kg; + try { + kg = KeyPairGenerator.getInstance("DSA"); + this.keyPair = kg.genKeyPair(); + this.saveKey(); + mXmppConnectionService.databaseBackend.updateAccount(account); + } catch (NoSuchAlgorithmException e) { + Log.d(Config.LOGTAG, + "error generating key pair " + e.getMessage()); + } + } + return this.keyPair; + } + + @Override + public String getReplyForUnreadableMessage(SessionID arg0) { + // TODO Auto-generated method stub + return null; + } + + @Override + public OtrPolicy getSessionPolicy(SessionID arg0) { + return otrPolicy; + } + + @Override + public void injectMessage(SessionID session, String body) + throws OtrException { + MessagePacket packet = new MessagePacket(); + packet.setFrom(account.getJid()); + if (session.getUserID().isEmpty()) { + packet.setAttribute("to", session.getAccountID()); + } else { + packet.setAttribute("to", session.getAccountID() + "/" + session.getUserID()); + } + packet.setBody(body); + MessageGenerator.addMessageHints(packet); + try { + Jid jid = OtrJidHelper.fromSessionID(session); + Conversation conversation = mXmppConnectionService.find(account, jid); + if (conversation != null && conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) { + if (mXmppConnectionService.sendChatStates()) { + packet.addChild(ChatState.toElement(conversation.getOutgoingChatState())); + } + } + } catch (final IllegalArgumentException ignored) { + + } + + packet.setType(MessagePacket.TYPE_CHAT); + packet.addChild("encryption", "urn:xmpp:eme:0").setAttribute("namespace", "urn:xmpp:otr:0"); + account.getXmppConnection().sendMessagePacket(packet); + } + + @Override + public void messageFromAnotherInstanceReceived(SessionID session) { + sendOtrErrorMessage(session, "Message from another OTR-instance received"); + } + + @Override + public void multipleInstancesDetected(SessionID arg0) { + // TODO Auto-generated method stub + + } + + @Override + public void requireEncryptedMessage(SessionID arg0, String arg1) + throws OtrException { + // TODO Auto-generated method stub + + } + + @Override + public void showError(SessionID arg0, String arg1) throws OtrException { + Log.d(Config.LOGTAG, "show error"); + } + + @Override + public void smpAborted(SessionID id) throws OtrException { + setSmpStatus(id, Conversation.Smp.STATUS_NONE); + } + + private void setSmpStatus(SessionID id, int status) { + try { + final Jid jid = OtrJidHelper.fromSessionID(id); + Conversation conversation = this.mXmppConnectionService.find(this.account, jid); + if (conversation != null) { + conversation.smp().status = status; + mXmppConnectionService.updateConversationUi(); + } + } catch (final IllegalArgumentException ignored) { + + } + } + + @Override + public void smpError(SessionID id, int arg1, boolean arg2) + throws OtrException { + setSmpStatus(id, Conversation.Smp.STATUS_NONE); + } + + @Override + public void unencryptedMessageReceived(SessionID arg0, String arg1) + throws OtrException { + throw new OtrException(new Exception("unencrypted message received")); + } + + @Override + public void unreadableMessageReceived(SessionID session) throws OtrException { + Log.d(Config.LOGTAG, "unreadable message received"); + sendOtrErrorMessage(session, "You sent me an unreadable OTR-encrypted message"); + } + + public void sendOtrErrorMessage(SessionID session, String errorText) { + try { + Jid jid = OtrJidHelper.fromSessionID(session); + Conversation conversation = mXmppConnectionService.find(account, jid); + String id = conversation == null ? null : conversation.getLastReceivedOtrMessageId(); + if (id != null) { + MessagePacket packet = mXmppConnectionService.getMessageGenerator() + .generateOtrError(jid, id, errorText); + packet.setFrom(account.getJid()); + mXmppConnectionService.sendMessagePacket(account, packet); + Log.d(Config.LOGTAG, packet.toString()); + Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + + ": unreadable OTR message in " + conversation.getName()); + } + } catch (IllegalArgumentException e) { + return; + } + } + + @Override + public void unverify(SessionID id, String arg1) { + setSmpStatus(id, Conversation.Smp.STATUS_FAILED); + } + + @Override + public void verify(SessionID id, String fingerprint, boolean approved) { + Log.d(Config.LOGTAG, "OtrService.verify(" + id.toString() + "," + fingerprint + "," + String.valueOf(approved) + ")"); + try { + final Jid jid = OtrJidHelper.fromSessionID(id); + Conversation conversation = this.mXmppConnectionService.find(this.account, jid); + if (conversation != null) { + if (approved) { + conversation.getContact().addOtrFingerprint(fingerprint); + } + conversation.smp().hint = null; + conversation.smp().status = Conversation.Smp.STATUS_VERIFIED; + mXmppConnectionService.updateConversationUi(); + mXmppConnectionService.syncRosterToDisk(conversation.getAccount()); + } + } catch (final IllegalArgumentException ignored) { + } + } + + @Override + public FragmenterInstructions getFragmenterInstructions(SessionID sessionID) { + return null; + } + +} diff --git a/src/main/java/eu/siacs/conversations/entities/Account.java b/src/main/java/eu/siacs/conversations/entities/Account.java index c471cabf3..582ed54dd 100644 --- a/src/main/java/eu/siacs/conversations/entities/Account.java +++ b/src/main/java/eu/siacs/conversations/entities/Account.java @@ -7,6 +7,13 @@ import android.util.Log; import com.google.common.base.Strings; +import net.java.otr4j.crypto.OtrCryptoEngineImpl; +import net.java.otr4j.crypto.OtrCryptoException; +import java.security.PublicKey; +import java.security.interfaces.DSAPublicKey; +import java.util.Locale; + + import org.json.JSONException; import org.json.JSONObject; @@ -24,6 +31,7 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.PgpDecryptionService; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession; +import eu.siacs.conversations.crypto.OtrService; import eu.siacs.conversations.services.AvatarService; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.utils.UIHelper; @@ -82,6 +90,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable protected String hostname = null; protected int port = 5222; protected boolean online = false; + private OtrService mOtrService = null; private String rosterVersion; private String displayName = null; private AxolotlService axolotlService = null; @@ -396,12 +405,16 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable } public void initAccountServices(final XmppConnectionService context) { + this.mOtrService = new OtrService(context, this); this.axolotlService = new AxolotlService(this, context); this.pgpDecryptionService = new PgpDecryptionService(context); if (xmppConnection != null) { xmppConnection.addOnAdvancedStreamFeaturesAvailableListener(axolotlService); } } + public OtrService getOtrService() { + return this.mOtrService; + } public PgpDecryptionService getPgpDecryptionService() { return this.pgpDecryptionService; @@ -415,6 +428,26 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable this.xmppConnection = connection; } + public String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (this.mOtrService == null) { + return null; + } + final PublicKey publicKey = this.mOtrService.getPublicKey(); + if (publicKey == null || !(publicKey instanceof DSAPublicKey)) { + return null; + } + this.otrFingerprint = new OtrCryptoEngineImpl().getFingerprint(publicKey).toLowerCase(Locale.US); + return this.otrFingerprint; + } catch (final OtrCryptoException ignored) { + return null; + } + } else { + return this.otrFingerprint; + } + } + public String getRosterVersion() { if (this.rosterVersion == null) { return ""; @@ -578,6 +611,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable private List<XmppUri.Fingerprint> getFingerprints() { ArrayList<XmppUri.Fingerprint> fingerprints = new ArrayList<>(); + final String otr = this.getOtrFingerprint(); + if (otr != null) { + fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OTR, otr)); + } if (axolotlService == null) { return fingerprints; } diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 3f8a6bd40..779da8132 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -516,6 +516,31 @@ public class Contact implements ListItem, Blockable { return avatar; } + public boolean deleteOtrFingerprint(String fingerprint) { + synchronized (this.keys) { + boolean success = false; + try { + if (this.keys.has("otr_fingerprints")) { + JSONArray newPrints = new JSONArray(); + JSONArray oldPrints = this.keys + .getJSONArray("otr_fingerprints"); + for (int i = 0; i < oldPrints.length(); ++i) { + if (!oldPrints.getString(i).equals(fingerprint)) { + newPrints.put(oldPrints.getString(i)); + } else { + success = true; + } + } + this.keys.put("otr_fingerprints", newPrints); + } + return success; + } catch (JSONException e) { + return false; + } + } + } + + public boolean mutualPresenceSubscription() { return getOption(Options.FROM) && getOption(Options.TO); } diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 1bf810216..9cd6cd435 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -8,6 +8,15 @@ import android.database.Cursor; import android.preference.PreferenceManager; import android.text.TextUtils; +import net.java.otr4j.OtrException; +import net.java.otr4j.crypto.OtrCryptoException; +import net.java.otr4j.session.SessionID; +import net.java.otr4j.session.SessionImpl; +import net.java.otr4j.session.SessionStatus; + +import java.security.interfaces.DSAPublicKey; +import java.util.Locale; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -82,10 +91,15 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private int mode; private JSONObject attributes; private Jid nextCounterpart; + private transient SessionImpl otrSession; + private transient String otrFingerprint = null; + private Smp mSmp = new Smp(); private transient MucOptions mucOptions = null; + private byte[] symmetricKey; private boolean messagesLeftOnServer = true; private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE; private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE; + private String mLastReceivedOtrMessageId = null; private String mFirstMamReference = null; public Conversation(final String name, final Account account, final Jid contactJid, @@ -411,6 +425,18 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } } + public void findUnsentMessagesWithEncryption(int encryptionType, OnMessageFound onMessageFound) { + synchronized (this.messages) { + for (Message message : this.messages) { + if ((message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_WAITING) + && (message.getEncryption() == encryptionType)) { + onMessageFound.onMessageFound(message); + } + } + } + } + + public void findUnsentTextMessages(OnMessageFound onMessageFound) { final ArrayList<Message> results = new ArrayList<>(); synchronized (this.messages) { @@ -582,6 +608,15 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return getContact().getBlockedJid(); } + public String getLastReceivedOtrMessageId() { + return this.mLastReceivedOtrMessageId; + } + + public void setLastReceivedOtrMessageId(String id) { + this.mLastReceivedOtrMessageId = id; + } + + public int countMessages() { synchronized (this.messages) { return this.messages.size(); @@ -778,6 +813,111 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl public void setMode(int mode) { this.mode = mode; } + public SessionImpl startOtrSession(String presence, boolean sendStart) { + if (this.otrSession != null) { + return this.otrSession; + } else { + final SessionID sessionId = new SessionID(this.getJid().asBareJid().toString(), + presence, + "xmpp"); + this.otrSession = new SessionImpl(sessionId, getAccount().getOtrService()); + try { + if (sendStart) { + this.otrSession.startSession(); + return this.otrSession; + } + return this.otrSession; + } catch (OtrException e) { + return null; + } + } + + } + + public SessionImpl getOtrSession() { + return this.otrSession; + } + + public void resetOtrSession() { + this.otrFingerprint = null; + this.otrSession = null; + this.mSmp.hint = null; + this.mSmp.secret = null; + this.mSmp.status = Smp.STATUS_NONE; + } + + public Smp smp() { + return mSmp; + } + + public boolean startOtrIfNeeded() { + if (this.otrSession != null && this.otrSession.getSessionStatus() != SessionStatus.ENCRYPTED) { + try { + this.otrSession.startSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + return true; + } + } + + public boolean endOtrIfNeeded() { + if (this.otrSession != null) { + if (this.otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + this.otrSession.endSession(); + this.resetOtrSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + this.resetOtrSession(); + return false; + } + } else { + return false; + } + } + + public boolean hasValidOtrSession() { + return this.otrSession != null; + } + + public synchronized String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (getOtrSession() == null || getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) { + return null; + } + DSAPublicKey remotePubKey = (DSAPublicKey) getOtrSession().getRemotePublicKey(); + this.otrFingerprint = getAccount().getOtrService().getFingerprint(remotePubKey).toLowerCase(Locale.US); + } catch (final OtrCryptoException ignored) { + return null; + } catch (final UnsupportedOperationException ignored) { + return null; + } + } + return this.otrFingerprint; + } + + public boolean verifyOtrFingerprint() { + final String fingerprint = getOtrFingerprint(); + if (fingerprint != null) { + getContact().addOtrFingerprint(fingerprint); + return true; + } else { + return false; + } + } + + public boolean isOtrFingerprintVerified() { + return getContact().getOtrFingerprints().contains(getOtrFingerprint()); + } /** * short for is Private and Non-anonymous @@ -814,7 +954,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } public int getNextEncryption() { - if (!Config.supportOmemo() && !Config.supportOpenPgp()) { + if (!Config.supportOmemo() && !Config.supportOpenPgp() && !Config.supportOtr()) { return Message.ENCRYPTION_NONE; } if (OmemoSetting.isAlways()) { @@ -830,8 +970,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl defaultEncryption = Message.ENCRYPTION_NONE; } int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption); - if (encryption == Message.ENCRYPTION_OTR || encryption < 0) { + if (encryption < 0) { return defaultEncryption; + } else if (encryption == Message.ENCRYPTION_OTR) { + return encryption; } else { return encryption; } @@ -846,6 +988,11 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return nextMessage == null ? "" : nextMessage; } + public boolean smpRequested() { + return smp().status == Smp.STATUS_CONTACT_REQUESTED; + } + + public @Nullable Draft getDraft() { final long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0); @@ -868,6 +1015,15 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return changed; } + public void setSymmetricKey(byte[] key) { + this.symmetricKey = key; + } + + public byte[] getSymmetricKey() { + return this.symmetricKey; + } + + public Bookmark getBookmark() { return this.account.getBookmark(this.contactJid); } @@ -1240,6 +1396,19 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } } + public class Smp { + public static final int STATUS_NONE = 0; + public static final int STATUS_CONTACT_REQUESTED = 1; + public static final int STATUS_WE_REQUESTED = 2; + public static final int STATUS_FAILED = 3; + public static final int STATUS_VERIFIED = 4; + + public String secret = null; + public String hint = null; + public int status = 0; + } + + public Message findDuplicateMessage(Message message) { return findDuplicateMessage(message, false); } diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java index 507c3550d..04e2c3a2d 100644 --- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -59,6 +59,9 @@ public abstract class AbstractGenerator { private final String[] PRIVACY_SENSITIVE = { "urn:xmpp:time" //XEP-0202: Entity Time leaks time zone }; + private final String[] OTR = { + "urn:xmpp:otr:0" + }; private final String[] VOIP_NAMESPACES = { Namespace.JINGLE_TRANSPORT_ICE_UDP, Namespace.JINGLE_FEATURE_AUDIO, @@ -139,6 +142,9 @@ public abstract class AbstractGenerator { features.addAll(Arrays.asList(PRIVACY_SENSITIVE)); features.addAll(Arrays.asList(VOIP_NAMESPACES)); } + if (Config.supportOtr()) { + features.addAll(Arrays.asList(OTR)); + } if (mXmppConnectionService.broadcastLastActivity()) { features.add(Namespace.IDLE); } diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index 2389f45e2..17c74c99c 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -6,6 +6,9 @@ import java.util.Date; import java.util.Locale; import java.util.TimeZone; +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; + import eu.siacs.conversations.Config; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; @@ -24,6 +27,7 @@ import eu.siacs.conversations.xmpp.jingle.Media; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; public class MessageGenerator extends AbstractGenerator { + public static final String OTR_FALLBACK_MESSAGE = "I would like to start a private (OTR encrypted) conversation but your client doesn’t seem to support that"; private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo"; private static final String PGP_FALLBACK_MESSAGE = "I sent you a PGP encrypted message but your client doesn’t seem to support that."; @@ -113,6 +117,31 @@ public class MessageGenerator extends AbstractGenerator { packet.addChild("no-permanent-storage", "urn:xmpp:hints"); //do not copy this. this is wrong. it is *store* } + + public MessagePacket generateOtrChat(Message message) { + Conversation conversation = (Conversation) message.getConversation(); + Session otrSession = conversation.getOtrSession(); + if (otrSession == null) { + return null; + } + MessagePacket packet = preparePacket(message); + addMessageHints(packet); + try { + String content; + if (message.hasFileOnRemoteHost()) { + content = message.getFileParams().url.toString(); + } else { + content = message.getBody(); + } + packet.setBody(otrSession.transformSending(content)[0]); + packet.addChild("encryption", "urn:xmpp:eme:0").setAttribute("namespace", "urn:xmpp:otr:0"); + return packet; + } catch (OtrException e) { + return null; + } + } + + public MessagePacket generateChat(Message message) { MessagePacket packet = preparePacket(message); String content; diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index db2222f25..d83b797f0 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -3,6 +3,12 @@ package eu.siacs.conversations.parser; import android.util.Log; import android.util.Pair; +import android.os.Build; +import android.text.Html; + +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionStatus; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -21,6 +27,7 @@ import eu.siacs.conversations.crypto.axolotl.BrokenSessionException; import eu.siacs.conversations.crypto.axolotl.NotEncryptedForThisDeviceException; import eu.siacs.conversations.crypto.axolotl.OutdatedSenderException; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; +import eu.siacs.conversations.crypto.OtrService; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Bookmark; import eu.siacs.conversations.entities.Contact; @@ -32,9 +39,11 @@ import eu.siacs.conversations.entities.MucOptions; import eu.siacs.conversations.entities.ReadByMarker; import eu.siacs.conversations.entities.ReceiptRequest; import eu.siacs.conversations.entities.RtpSessionStatus; +import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.http.HttpConnectionManager; import eu.siacs.conversations.services.MessageArchiveService; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.Namespace; import eu.siacs.conversations.xml.Element; @@ -51,7 +60,7 @@ import eu.siacs.conversations.xmpp.stanzas.MessagePacket; public class MessageParser extends AbstractParser implements OnMessagePacketReceived { - + private static final List<String> CLIENTS_SENDING_HTML_IN_OTR = Arrays.asList("Pidgin", "Adium", "Trillian"); private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss", Locale.ENGLISH); private static final List<String> JINGLE_MESSAGE_ELEMENT_NAMES = Arrays.asList("accept", "propose", "proceed", "reject", "retract"); @@ -96,6 +105,32 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return result != null ? result : fallback; } + + private static boolean clientMightSendHtml(Account account, Jid from) { + String resource = from.getResource(); + if (resource == null) { + return false; + } + Presence presence = account.getRoster().getContact(from).getPresences().getPresencesMap().get(resource); + ServiceDiscoveryResult disco = presence == null ? null : presence.getServiceDiscoveryResult(); + if (disco == null) { + return false; + } + return hasIdentityKnowForSendingHtml(disco.getIdentities()); + } + + private static boolean hasIdentityKnowForSendingHtml(List<ServiceDiscoveryResult.Identity> identities) { + for (ServiceDiscoveryResult.Identity identity : identities) { + if (identity.getName() != null) { + if (CLIENTS_SENDING_HTML_IN_OTR.contains(identity.getName())) { + return true; + } + } + } + return false; + } + + private boolean extractChatState(Conversation c, final boolean isTypeGroupChat, final MessagePacket packet) { ChatState state = ChatState.parse(packet); if (state != null && c != null) { @@ -127,6 +162,67 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return false; } + private Message parseOtrChat(String body, Jid from, String id, Conversation conversation) { + String presence; + if (from.isBareJid()) { + presence = ""; + } else { + presence = from.getResource(); + } + if (body.matches("^\\?OTRv\\d{1,2}\\?.*")) { + conversation.endOtrIfNeeded(); + } + if (!conversation.hasValidOtrSession()) { + conversation.startOtrSession(presence, false); + } else { + String foreignPresence = conversation.getOtrSession().getSessionID().getUserID(); + if (!foreignPresence.equals(presence)) { + conversation.endOtrIfNeeded(); + conversation.startOtrSession(presence, false); + } + } + try { + conversation.setLastReceivedOtrMessageId(id); + Session otrSession = conversation.getOtrSession(); + body = otrSession.transformReceiving(body); + SessionStatus status = otrSession.getSessionStatus(); + if (body == null && status == SessionStatus.ENCRYPTED) { + mXmppConnectionService.onOtrSessionEstablished(conversation); + return null; + } else if (body == null && status == SessionStatus.FINISHED) { + conversation.resetOtrSession(); + mXmppConnectionService.updateConversationUi(); + return null; + } else if (body == null || (body.isEmpty())) { + return null; + } + if (body.startsWith(CryptoHelper.FILETRANSFER)) { + String key = body.substring(CryptoHelper.FILETRANSFER.length()); + conversation.setSymmetricKey(CryptoHelper.hexToBytes(key)); + return null; + } + if (clientMightSendHtml(conversation.getAccount(), from)) { + Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": received OTR message from bad behaving client. escaping HTML…"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + body = Html.fromHtml(body, Html.FROM_HTML_MODE_LEGACY).toString(); + } else { + body = Html.fromHtml(body).toString(); + } + } + + final OtrService otrService = conversation.getAccount().getOtrService(); + Message finishedMessage = new Message(conversation, body, Message.ENCRYPTION_OTR, Message.STATUS_RECEIVED); + finishedMessage.setFingerprint(otrService.getFingerprint(otrSession.getRemotePublicKey())); + conversation.setLastReceivedOtrMessageId(null); + + return finishedMessage; + } catch (Exception e) { + conversation.resetOtrSession(); + return null; + } + } + + private Message parseAxolotlChat(Element axolotlMessage, Jid from, Conversation conversation, int status, final boolean checkedForDuplicates, boolean postpone) { final AxolotlService service = conversation.getAccount().getAxolotlService(); final XmppAxolotlMessage xmppAxolotlMessage; @@ -350,6 +446,14 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } } + + if (message != null) { + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + Conversation conversation = (Conversation) message.getConversation(); + conversation.endOtrIfNeeded(); + } + } + } return true; } @@ -509,7 +613,20 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } final Message message; - if (pgpEncrypted != null && Config.supportOpenPgp()) { + if (body != null && body.content.startsWith("?OTR") && Config.supportOtr()) { + if (!isForwarded && !isTypeGroupChat && isProperlyAddressed && !conversationMultiMode) { + message = parseOtrChat(body.content, from, remoteMsgId, conversation); + if (message == null) { + return; + } + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring OTR message from " + from + " isForwarded=" + Boolean.toString(isForwarded) + ", isProperlyAddressed=" + Boolean.valueOf(isProperlyAddressed)); + message = new Message(conversation, body.content, Message.ENCRYPTION_NONE, status); + if (body.count > 1) { + message.setBodyLanguage(body.language); + } + } + } else if (pgpEncrypted != null && Config.supportOpenPgp()) { message = new Message(conversation, pgpEncrypted, Message.ENCRYPTION_PGP, status); } else if (axolotlEncrypted != null && Config.supportOmemo()) { Jid origin; @@ -823,6 +940,14 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece processMessageReceipts(account, packet, remoteMsgId, query); } + + if (message.getStatus() == Message.STATUS_RECEIVED + && conversation.getOtrSession() != null + && !conversation.getOtrSession().getSessionID().getUserID() + .equals(message.getCounterpart().getResource())) { + conversation.endOtrIfNeeded(); + } + mXmppConnectionService.databaseBackend.createMessage(message); final HttpConnectionManager manager = this.mXmppConnectionService.getHttpConnectionManager(); if ((mXmppConnectionService.easyDownloader() || message.trusted()) && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) { diff --git a/src/main/java/eu/siacs/conversations/services/EmojiService.java b/src/main/java/eu/siacs/conversations/services/EmojiService.java new file mode 100644 index 000000000..daec95252 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/services/EmojiService.java @@ -0,0 +1,53 @@ +package eu.siacs.conversations.services; + +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import androidx.core.provider.FontRequest; +import androidx.emoji.text.EmojiCompat; +import androidx.emoji.text.FontRequestEmojiCompatConfig; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; + +public class EmojiService { + + private final EmojiCompat.InitCallback initCallback = new EmojiCompat.InitCallback() { + @Override + public void onInitialized() { + super.onInitialized(); + Log.d(Config.LOGTAG, "EmojiService succeeded in loading fonts"); + + } + + @Override + public void onFailed(Throwable throwable) { + super.onFailed(throwable); + Log.d(Config.LOGTAG, "EmojiService failed to load fonts", throwable); + } + }; + + private final Context context; + + public EmojiService(Context context) { + this.context = context; + } + + public void init(boolean useBundledEmoji) { + final FontRequest fontRequest = new FontRequest( + "com.google.android.gms.fonts", + "com.google.android.gms", + "Noto Color Emoji Compat", + R.array.font_certs); + FontRequestEmojiCompatConfig fontRequestEmojiCompatConfig = new FontRequestEmojiCompatConfig(context, fontRequest); + fontRequestEmojiCompatConfig.registerInitCallback(initCallback); + //On recent Androids we assume to have the latest emojis + //there are some annoying bugs with emoji compat that make it a safer choice not to use it when possible + // a) when using the ondemand emoji font (play store) flags don’t work + // b) the text preview has annoying glitches when the cut of text contains emojis (the emoji will be half visible) + // c) can trigger a hardware rendering bug https://issuetracker.google.com/issues/67102093 + fontRequestEmojiCompatConfig.setReplaceAll(useBundledEmoji && Build.VERSION.SDK_INT < Build.VERSION_CODES.O); + EmojiCompat.init(fontRequestEmojiCompatConfig); + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index ab025ab59..09b4a0b38 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -54,6 +54,14 @@ import android.util.DisplayMetrics; import android.util.Log; import android.util.LruCache; import android.util.Pair; +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionID; +import net.java.otr4j.session.SessionImpl; +import net.java.otr4j.session.SessionStatus; +import eu.siacs.conversations.xmpp.jid.OtrJidHelper; +import eu.siacs.conversations.xmpp.Jid; + import androidx.annotation.BoolRes; import androidx.annotation.IntegerRes; @@ -253,9 +261,18 @@ public class XmppConnectionService extends Service { Conversation conversation = find(getConversations(), contact); if (conversation != null) { if (online) { + conversation.endOtrIfNeeded(); if (contact.getPresences().size() == 1) { sendUnsentMessages(conversation); } + } else { + //check if the resource we are haveing a conversation with is still online + if (conversation.hasValidOtrSession()) { + String otrResource = conversation.getOtrSession().getSessionID().getUserID(); + if (!(Arrays.asList(contact.getPresences().toResourceArray()).contains(otrResource))) { + conversation.endOtrIfNeeded(); + } + } } } }; @@ -436,6 +453,9 @@ public class XmppConnectionService extends Service { if (conversation.getAccount() == account && !pendingJoin && !inProgressJoin) { + if (!conversation.startOtrIfNeeded()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": couldn't start OTR with " + conversation.getContact().getJid() + " when needed"); + } sendUnsentMessages(conversation); } } @@ -1770,6 +1790,12 @@ public class XmppConnectionService extends Service { } } + if (!resend && message.getEncryption() != Message.ENCRYPTION_OTR) { + conversation.endOtrIfNeeded(); + conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR, + message1 -> markMessage(message1, Message.STATUS_SEND_FAILED)); + } + final boolean inProgressJoin = isJoinInProgress(conversation); if (account.isOnlineAndConnected() && !inProgressJoin) { @@ -1801,6 +1827,30 @@ public class XmppConnectionService extends Service { packet = mMessageGenerator.generatePgpChat(message); } break; + case Message.ENCRYPTION_OTR: + SessionImpl otrSession = conversation.getOtrSession(); + if (otrSession != null && otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + message.setCounterpart(OtrJidHelper.fromSessionID(otrSession.getSessionID())); + } catch (IllegalArgumentException e) { + break; + } + if (message.needsUploading()) { + mJingleConnectionManager.startJingleFileTransfer(message); + } else { + packet = mMessageGenerator.generateOtrChat(message); + } + } else if (otrSession == null) { + if (message.fixCounterpart()) { + conversation.startOtrSession(message.getCounterpart().getResource(), true); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not fix counterpart for OTR message to contact " + message.getCounterpart()); + break; + } + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + " OTR session with " + message.getContact() + " is in wrong state: " + otrSession.getSessionStatus().toString()); + } + break; case Message.ENCRYPTION_AXOLOTL: message.setFingerprint(account.getAxolotlService().getOwnFingerprint()); if (message.needsUploading()) { @@ -1853,6 +1903,12 @@ public class XmppConnectionService extends Service { } } break; + case Message.ENCRYPTION_OTR: + if (!conversation.hasValidOtrSession() && message.getCounterpart() != null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": create otr session without starting for " + message.getContact().getJid()); + conversation.startOtrSession(message.getCounterpart().getResource(), false); + } + break; case Message.ENCRYPTION_AXOLOTL: message.setFingerprint(account.getAxolotlService().getOwnFingerprint()); break; @@ -3757,6 +3813,12 @@ public class XmppConnectionService extends Service { if (conversation.getAccount() == account) { if (conversation.getMode() == Conversation.MODE_MULTI) { leaveMuc(conversation, true); + } else { + if (conversation.endOtrIfNeeded()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + + ": ended otr session with " + + conversation.getJid()); + } } } } @@ -3814,6 +3876,66 @@ public class XmppConnectionService extends Service { pushContactToServer(contact, preAuth); } + public void onOtrSessionEstablished(Conversation conversation) { + final Account account = conversation.getAccount(); + final Session otrSession = conversation.getOtrSession(); + Log.d(Config.LOGTAG, + account.getJid().asBareJid() + " otr session established with " + + conversation.getJid() + "/" + + otrSession.getSessionID().getUserID()); + conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR, new Conversation.OnMessageFound() { + + @Override + public void onMessageFound(Message message) { + SessionID id = otrSession.getSessionID(); + try { + message.setCounterpart(Jid.of(id.getAccountID() + "/" + id.getUserID())); + } catch (IllegalArgumentException e) { + return; + } + if (message.needsUploading()) { + mJingleConnectionManager.startJingleFileTransfer(message); + } else { + MessagePacket outPacket = mMessageGenerator.generateOtrChat(message); + if (outPacket != null) { + mMessageGenerator.addDelay(outPacket, message.getTimeSent()); + message.setStatus(Message.STATUS_SEND); + databaseBackend.updateMessage(message, false); + sendMessagePacket(account, outPacket); + } + } + updateConversationUi(); + } + }); + } + + public boolean renewSymmetricKey(Conversation conversation) { + Account account = conversation.getAccount(); + byte[] symmetricKey = new byte[32]; + this.mRandom.nextBytes(symmetricKey); + Session otrSession = conversation.getOtrSession(); + if (otrSession != null) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_CHAT); + packet.setFrom(account.getJid()); + MessageGenerator.addMessageHints(packet); + packet.setAttribute("to", otrSession.getSessionID().getAccountID() + "/" + + otrSession.getSessionID().getUserID()); + try { + packet.setBody(otrSession + .transformSending(CryptoHelper.FILETRANSFER + + CryptoHelper.bytesToHex(symmetricKey))[0]); + sendMessagePacket(account, packet); + conversation.setSymmetricKey(symmetricKey); + return true; + } catch (OtrException e) { + return false; + } + } + return false; + } + + public void pushContactToServer(final Contact contact) { pushContactToServer(contact, null); } @@ -5163,7 +5285,10 @@ public class XmppConnectionService extends Service { boolean performedVerification = false; final AxolotlService axolotlService = contact.getAccount().getAxolotlService(); for (XmppUri.Fingerprint fp : fingerprints) { - if (fp.type == XmppUri.FingerprintType.OMEMO) { + if (fp.type == XmppUri.FingerprintType.OTR) { + performedVerification |= contact.addOtrFingerprint(fp.fingerprint); + needsRosterWrite |= performedVerification; + } else if (fp.type == XmppUri.FingerprintType.OMEMO) { String fingerprint = "05" + fp.fingerprint.replaceAll("\\s", ""); FingerprintStatus fingerprintStatus = axolotlService.getFingerprintTrust(fingerprint); if (fingerprintStatus != null) { diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index cfaa9d585..721d2f9d9 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -51,6 +51,7 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession; +import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.databinding.ActivityContactDetailsBinding; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; @@ -680,6 +681,26 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp binding.detailsContactKeys.removeAllViews(); boolean hasKeys = false; LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + if (Config.supportOtr()) { + for (final String otrFingerprint : contact.getOtrFingerprints()) { + hasKeys = true; + View view = inflater.inflate(R.layout.contact_key, binding.detailsContactKeys, false); + TextView key = view.findViewById(R.id.key); + TextView keyType = view.findViewById(R.id.key_type); + ImageButton removeButton = view + .findViewById(R.id.button_remove); + removeButton.setVisibility(View.VISIBLE); + key.setText(CryptoHelper.prettifyFingerprint(otrFingerprint)); + if (otrFingerprint != null && otrFingerprint.equalsIgnoreCase(messageFingerprint)) { + keyType.setText(R.string.otr_fingerprint_selected_message); + keyType.setTextColor(ContextCompat.getColor(this, R.color.accent)); + } else { + keyType.setText(R.string.otr_fingerprint); + } + binding.detailsContactKeys.addView(view); + removeButton.setOnClickListener(v -> confirmToDeleteFingerprint(otrFingerprint)); + } + } final AxolotlService axolotlService = contact.getAccount().getAxolotlService(); if (Config.supportOmemo() && axolotlService != null) { final Collection<XmppAxolotlSession> sessions = axolotlService.findSessionsForContact(contact); @@ -768,6 +789,20 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp } } } + protected void confirmToDeleteFingerprint(final String fingerprint) { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.delete_fingerprint); + builder.setMessage(R.string.sure_delete_fingerprint); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.delete, + (dialog, which) -> { + if (contact.deleteOtrFingerprint(fingerprint)) { + populateView(); + xmppConnectionService.syncRosterToDisk(contact.getAccount()); + } + }); + builder.create().show(); + } public void onBackendConnected() { if (accountJid != null && contactJid != null) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 1deba4c30..c4b32d857 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.ui; +import net.java.otr4j.session.SessionStatus; + import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static eu.siacs.conversations.ui.SettingsActivity.HIDE_YOU_ARE_NOT_PARTICIPATING; @@ -208,6 +210,12 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke private Toast messageLoaderToast; private ConversationsActivity activity; private Menu mOptionsMenu; + protected OnClickListener clickToVerify = new OnClickListener() { + @Override + public void onClick(View v) { + activity.verifyOtrSessionDialog(conversation, v); + } + }; private final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd hh:mm (z)", Locale.US); @@ -504,6 +512,18 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } } }; + private OnClickListener mAnswerSmpClickListener = new OnClickListener() { + @Override + public void onClick(View view) { + Intent intent = new Intent(activity, VerifyOTRActivity.class); + intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT); + intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + intent.putExtra(VerifyOTRActivity.EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + intent.putExtra("mode", VerifyOTRActivity.MODE_ANSWER_QUESTION); + startActivity(intent); + activity.overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + } + }; protected OnClickListener clickToDecryptListener = new OnClickListener() { @@ -917,6 +937,9 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke message.setUuid(UUID.randomUUID().toString()); } switch (conversation.getNextEncryption()) { + case Message.ENCRYPTION_OTR: + sendOtrMessage(message); + break; case Message.ENCRYPTION_PGP: sendPgpMessage(message); break; @@ -1604,6 +1627,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } switch (item.getItemId()) { case R.id.encryption_choice_axolotl: + case R.id.encryption_choice_otr: case R.id.encryption_choice_pgp: case R.id.encryption_choice_none: handleEncryptionSelection(item); @@ -1785,6 +1809,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE); item.setChecked(true); break; + case R.id.encryption_choice_otr: + updated = conversation.setNextEncryption(Message.ENCRYPTION_OTR); + item.setChecked(true); + break; case R.id.encryption_choice_pgp: if (activity.hasPgp()) { if (conversation.getAccount().getPgpSignature() != null) { @@ -1999,6 +2027,20 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke activity.xmppConnectionService.getHttpConnectionManager().createNewDownloadConnection(message, true); } + private OnClickListener OTRwarning = new OnClickListener() { + @Override + public void onClick(View v) { + try { + final Uri uri = Uri.parse("https://monocles.wiki/index.php?title=Monocles_chat"); + Intent browserIntent = new Intent(Intent.ACTION_VIEW, uri); + startActivity(browserIntent); + } catch (Exception e) { + ToastCompat.makeText(activity, R.string.no_application_found_to_open_link, Toast.LENGTH_SHORT).show(); + } + } + }; + + @SuppressLint("InflateParams") protected void clearHistoryDialog(final Conversation conversation) { final AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); @@ -2852,9 +2894,27 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } } else if (account.hasPendingPgpIntent(conversation)) { showSnackbar(R.string.openpgp_messages_found, R.string.decrypt, clickToDecryptListener); + } else if (mode == Conversation.MODE_SINGLE + && conversation.smpRequested()) { + showSnackbar(R.string.smp_requested, R.string.verify, this.mAnswerSmpClickListener); + } else if (mode == Conversation.MODE_SINGLE + && conversation.getNextEncryption() == Message.ENCRYPTION_OTR) { + showSnackbar(R.string.otr_warning, R.string.readmore, OTRwarning); + } else if (mode == Conversation.MODE_SINGLE + && conversation.hasValidOtrSession() + && (conversation.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED) + && (!conversation.isOtrFingerprintVerified())) { + showSnackbar(R.string.unknown_otr_fingerprint, R.string.verify, clickToVerify); + } else if (connection != null + && connection.getFeatures().blocking() + && conversation.countMessages() != 0 + && !conversation.isBlocked() + && conversation.isWithStranger()) { + showSnackbar(R.string.received_message_from_stranger, R.string.block, mBlockClickListener); } else if (activity.warnUnecryptedChat()) { if (conversation.getNextEncryption() == Message.ENCRYPTION_NONE && conversation.isSingleOrPrivateAndNonAnonymous() && ((Config.supportOmemo() && Conversation.suitableForOmemoByDefault(conversation)) || - (Config.supportOpenPgp() && account.isPgpDecryptionServiceConnected()))) { + (Config.supportOpenPgp() && account.isPgpDecryptionServiceConnected()) || ( + mode == Conversation.MODE_SINGLE && Config.supportOtr()))) { if (ENCRYPTION_EXCEPTIONS.contains(conversation.getJid().toString()) || conversation.getJid().toString().equals(account.getJid().getDomain())) { hideSnackbar(); } else { @@ -3245,6 +3305,16 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke builder.setPositiveButton(getString(R.string.send_unencrypted), listener); builder.create().show(); } + protected void sendOtrMessage(final Message message) { + final ConversationsActivity activity = (ConversationsActivity) getActivity(); + final XmppConnectionService xmppService = activity.xmppConnectionService; + activity.selectPresence(conversation, + () -> { + message.setCounterpart(conversation.getNextCounterpart()); + xmppService.sendMessage(message); + messageSent(); + }); + } public void appendText(String text, final boolean doNotAppend) { if (text == null) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 400f6f7de..3a805b037 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -57,11 +57,15 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.TextView; +import android.widget.Toast; + +import net.java.otr4j.session.SessionStatus; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.Toolbar; import androidx.databinding.DataBindingUtil; @@ -971,6 +975,32 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio } } } + public void verifyOtrSessionDialog(final Conversation conversation, View view) { + if (!conversation.hasValidOtrSession() || conversation.getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) { + ToastCompat.makeText(this, R.string.otr_session_not_started, Toast.LENGTH_LONG).show(); + return; + } + if (view == null) { + return; + } + PopupMenu popup = new PopupMenu(this, view); + popup.inflate(R.menu.verification_choices); + popup.setOnMenuItemClickListener(menuItem -> { + Intent intent = new Intent(ConversationsActivity.this, VerifyOTRActivity.class); + intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT); + intent.putExtra("contact", conversation.getContact().getJid().asBareJid().toString()); + intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + switch (menuItem.getItemId()) { + case R.id.ask_question: + intent.putExtra("mode", VerifyOTRActivity.MODE_ASK_QUESTION); + break; + } + startActivity(intent); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + }); + popup.show(); + } @Override public void onConversationArchived(Conversation conversation) { diff --git a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java index fe4ed0fb1..d46b6f004 100644 --- a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java @@ -36,6 +36,7 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; import androidx.databinding.DataBindingUtil; import com.google.android.material.textfield.TextInputLayout; @@ -1305,6 +1306,25 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat } else { this.binding.pgpFingerprintBox.setVisibility(View.GONE); } + final String otrFingerprint = this.mAccount.getOtrFingerprint(); + if (otrFingerprint != null && Config.supportOtr()) { + if ("otr".equals(messageFingerprint)) { + this.binding.otrFingerprintDesc.setTextColor(ContextCompat.getColor(this, R.color.accent)); + } + this.binding.otrFingerprintBox.setVisibility(View.VISIBLE); + this.binding.otrFingerprint.setText(CryptoHelper.prettifyFingerprint(otrFingerprint)); + this.binding.actionCopyToClipboard.setVisibility(View.VISIBLE); + this.binding.actionCopyToClipboard.setOnClickListener(v -> { + if (copyTextToClipboard(CryptoHelper.prettifyFingerprint(otrFingerprint), R.string.otr_fingerprint)) { + ToastCompat.makeText( + EditAccountActivity.this, + R.string.toast_message_otr_fingerprint, + Toast.LENGTH_SHORT).show(); + } + }); + } else { + this.binding.otrFingerprintBox.setVisibility(View.GONE); + } final String ownAxolotlFingerprint = this.mAccount.getAxolotlService().getOwnFingerprint(); if (ownAxolotlFingerprint != null && Config.supportOmemo()) { this.binding.axolotlFingerprintBox.setVisibility(View.VISIBLE); diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index fd16fe51f..5d4ae1834 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -86,6 +86,7 @@ public class SettingsActivity extends XmppActivity implements public static final String MAPPREVIEW_HOST = "mappreview_host"; public static final String ALLOW_MESSAGE_CORRECTION = "allow_message_correction"; public static final String ALLOW_MESSAGE_RETRACTION = "allow_message_retraction"; + public static final String ENABLE_OTR_ENCRYPTION = "enable_otr_encryption"; public static final String USE_UNICOLORED_CHATBG = "unicolored_chatbg"; public static final String EASY_DOWNLOADER = "easy_downloader"; public static final String MIN_ANDROID_SDK21_SHOWN = "min_android_sdk21_shown"; diff --git a/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java new file mode 100644 index 000000000..6528fb38a --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java @@ -0,0 +1,450 @@ +package eu.siacs.conversations.ui; + +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; + +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.utils.XmppUri; +import eu.siacs.conversations.xmpp.Jid; +import me.drakeet.support.toast.ToastCompat; + +public class VerifyOTRActivity extends XmppActivity implements XmppConnectionService.OnConversationUpdate { + + public static final String ACTION_VERIFY_CONTACT = "verify_contact"; + public static final int MODE_SCAN_FINGERPRINT = -0x0502; + public static final int MODE_ASK_QUESTION = 0x0503; + public static final int MODE_ANSWER_QUESTION = 0x0504; + public static final int MODE_MANUAL_VERIFICATION = 0x0505; + + private LinearLayout mManualVerificationArea; + private LinearLayout mSmpVerificationArea; + private TextView mRemoteFingerprint; + private TextView mYourFingerprint; + private TextView mVerificationExplain; + private TextView mStatusMessage; + private TextView mSharedSecretHint; + private EditText mSharedSecretHintEditable; + private EditText mSharedSecretSecret; + private Button mLeftButton; + private Button mRightButton; + private Account mAccount; + private Conversation mConversation; + private int mode = MODE_MANUAL_VERIFICATION; + private XmppUri mPendingUri = null; + + private DialogInterface.OnClickListener mVerifyFingerprintListener = new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialogInterface, int click) { + mConversation.verifyOtrFingerprint(); + xmppConnectionService.syncRosterToDisk(mConversation.getAccount()); + ToastCompat.makeText(VerifyOTRActivity.this, R.string.verified, Toast.LENGTH_SHORT).show(); + finish(); + } + }; + + private View.OnClickListener mCreateSharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(final View view) { + if (isAccountOnline()) { + final String question = mSharedSecretHintEditable.getText().toString(); + final String secret = mSharedSecretSecret.getText().toString(); + if (question.trim().isEmpty()) { + mSharedSecretHintEditable.requestFocus(); + mSharedSecretHintEditable.setError(getString(R.string.shared_secret_hint_should_not_be_empty)); + } else if (secret.trim().isEmpty()) { + mSharedSecretSecret.requestFocus(); + mSharedSecretSecret.setError(getString(R.string.shared_secret_can_not_be_empty)); + } else { + mSharedSecretSecret.setError(null); + mSharedSecretHintEditable.setError(null); + initSmp(question, secret); + updateView(); + } + } + } + }; + private View.OnClickListener mCancelSharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + if (isAccountOnline()) { + abortSmp(); + updateView(); + } + } + }; + private View.OnClickListener mRespondSharedSecretListener = new View.OnClickListener() { + + @Override + public void onClick(View view) { + if (isAccountOnline()) { + final String question = mSharedSecretHintEditable.getText().toString(); + final String secret = mSharedSecretSecret.getText().toString(); + respondSmp(question, secret); + updateView(); + } + } + }; + private View.OnClickListener mRetrySharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + mConversation.smp().hint = null; + mConversation.smp().secret = null; + updateView(); + } + }; + private View.OnClickListener mFinishListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + finish(); + } + }; + + protected boolean initSmp(final String question, final String secret) { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.initSmp(question, secret); + mConversation.smp().status = Conversation.Smp.STATUS_WE_REQUESTED; + mConversation.smp().secret = secret; + mConversation.smp().hint = question; + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean abortSmp() { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.abortSmp(); + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + mConversation.smp().hint = null; + mConversation.smp().secret = null; + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean respondSmp(final String question, final String secret) { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.respondSmp(question, secret); + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean verifyWithUri(XmppUri uri) { + Contact contact = mConversation.getContact(); + if (this.mConversation.getContact().getJid().equals(uri.getJid()) && uri.hasFingerprints()) { + xmppConnectionService.verifyFingerprints(contact, uri.getFingerprints()); + ToastCompat.makeText(this, R.string.verified, Toast.LENGTH_SHORT).show(); + updateView(); + return true; + } else { + ToastCompat.makeText(this, R.string.could_not_verify_fingerprint, Toast.LENGTH_SHORT).show(); + return false; + } + } + + protected boolean isAccountOnline() { + if (this.mAccount.getStatus() != Account.State.ONLINE) { + ToastCompat.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show(); + return false; + } else { + return true; + } + } + + protected boolean handleIntent(Intent intent) { + if (intent != null && intent.getAction().equals(ACTION_VERIFY_CONTACT)) { + this.mAccount = extractAccount(intent); + if (this.mAccount == null) { + return false; + } + try { + this.mConversation = this.xmppConnectionService.find(this.mAccount, Jid.of(intent.getExtras().getString("contact"))); + if (this.mConversation == null) { + return false; + } + } catch (final IllegalArgumentException ignored) { + ignored.printStackTrace(); + return false; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + this.mode = intent.getIntExtra("mode", MODE_MANUAL_VERIFICATION); + // todo scan OTR fingerprint + if (this.mode == MODE_SCAN_FINGERPRINT) { + Log.d(Config.LOGTAG, "Scan OTR fingerprint is not implemented in this version"); + //new IntentIntegrator(this).initiateScan(); + return false; + } + return true; + } else { + return false; + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + // todo onActivityResult for OTR scan + Log.d(Config.LOGTAG, "Scan OTR fingerprint result is not implemented in this version"); + /*if ((requestCode & 0xFFFF) == IntentIntegrator.REQUEST_CODE) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent); + if (scanResult != null && scanResult.getFormatName() != null) { + String data = scanResult.getContents(); + XmppUri uri = new XmppUri(data); + if (xmppConnectionServiceBound) { + verifyWithUri(uri); + finish(); + } else { + this.mPendingUri = uri; + } + } else { + finish(); + } + }*/ + super.onActivityResult(requestCode, requestCode, intent); + } + + @Override + protected void onBackendConnected() { + if (handleIntent(getIntent())) { + updateView(); + } else if (mPendingUri != null) { + verifyWithUri(mPendingUri); + finish(); + mPendingUri = null; + } + setIntent(null); + } + + protected void updateView() { + if (this.mConversation != null && this.mConversation.hasValidOtrSession()) { + final ActionBar actionBar = getSupportActionBar(); + this.mVerificationExplain.setText(R.string.no_otr_session_found); + invalidateOptionsMenu(); + switch (this.mode) { + case MODE_ASK_QUESTION: + if (actionBar != null) { + actionBar.setTitle(R.string.ask_question); + } + this.updateViewAskQuestion(); + break; + case MODE_ANSWER_QUESTION: + if (actionBar != null) { + actionBar.setTitle(R.string.smp_requested); + } + this.updateViewAnswerQuestion(); + break; + case MODE_MANUAL_VERIFICATION: + default: + if (actionBar != null) { + actionBar.setTitle(R.string.manually_verify); + } + this.updateViewManualVerification(); + break; + } + } else { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.GONE); + } + } + + protected void updateViewManualVerification() { + this.mVerificationExplain.setText(R.string.manual_verification_explanation); + this.mManualVerificationArea.setVisibility(View.VISIBLE); + this.mSmpVerificationArea.setVisibility(View.GONE); + this.mYourFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mAccount.getOtrFingerprint())); + this.mRemoteFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mConversation.getOtrFingerprint())); + if (this.mConversation.isOtrFingerprintVerified()) { + deactivateButton(this.mRightButton, R.string.verified); + activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + } else { + activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + activateButton(this.mRightButton, R.string.verify, new View.OnClickListener() { + @Override + public void onClick(View view) { + showManuallyVerifyDialog(); + } + }); + } + } + + protected void updateViewAskQuestion() { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.VISIBLE); + this.mVerificationExplain.setText(R.string.smp_explain_question); + final int smpStatus = this.mConversation.smp().status; + switch (smpStatus) { + case Conversation.Smp.STATUS_WE_REQUESTED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.mSharedSecretHintEditable.setText(this.mConversation.smp().hint); + this.mSharedSecretSecret.setText(this.mConversation.smp().secret); + this.activateButton(this.mLeftButton, R.string.cancel, this.mCancelSharedSecretListener); + this.deactivateButton(this.mRightButton, R.string.in_progress); + break; + case Conversation.Smp.STATUS_FAILED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.requestFocus(); + this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match)); + this.deactivateButton(this.mLeftButton, R.string.cancel); + this.activateButton(this.mRightButton, R.string.try_again, this.mRetrySharedSecretListener); + break; + case Conversation.Smp.STATUS_VERIFIED: + this.mSharedSecretHintEditable.setText(""); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretSecret.setText(""); + this.mSharedSecretSecret.setVisibility(View.GONE); + this.mStatusMessage.setVisibility(View.VISIBLE); + this.deactivateButton(this.mLeftButton, R.string.cancel); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + default: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + this.activateButton(this.mRightButton, R.string.ask_question, this.mCreateSharedSecretListener); + break; + } + } + + protected void updateViewAnswerQuestion() { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.VISIBLE); + this.mVerificationExplain.setText(R.string.smp_explain_answer); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretHint.setVisibility(View.VISIBLE); + this.deactivateButton(this.mLeftButton, R.string.cancel); + final int smpStatus = this.mConversation.smp().status; + switch (smpStatus) { + case Conversation.Smp.STATUS_CONTACT_REQUESTED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHint.setText(this.mConversation.smp().hint); + this.activateButton(this.mRightButton, R.string.respond, this.mRespondSharedSecretListener); + break; + case Conversation.Smp.STATUS_VERIFIED: + this.mSharedSecretHintEditable.setText(""); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretHint.setVisibility(View.GONE); + this.mSharedSecretSecret.setText(""); + this.mSharedSecretSecret.setVisibility(View.GONE); + this.mStatusMessage.setVisibility(View.VISIBLE); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + case Conversation.Smp.STATUS_FAILED: + default: + this.mSharedSecretSecret.requestFocus(); + this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match)); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + } + } + + protected void activateButton(Button button, int text, View.OnClickListener listener) { + button.setEnabled(true); + button.setText(text); + button.setOnClickListener(listener); + } + + protected void deactivateButton(Button button, int text) { + button.setEnabled(false); + button.setText(text); + button.setOnClickListener(null); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_verify_otr); + this.mRemoteFingerprint = findViewById(R.id.remote_fingerprint); + this.mYourFingerprint = findViewById(R.id.your_fingerprint); + this.mLeftButton = findViewById(R.id.left_button); + this.mRightButton = findViewById(R.id.right_button); + this.mVerificationExplain = findViewById(R.id.verification_explanation); + this.mStatusMessage = findViewById(R.id.status_message); + this.mSharedSecretSecret = findViewById(R.id.shared_secret_secret); + this.mSharedSecretHintEditable = findViewById(R.id.shared_secret_hint_editable); + this.mSharedSecretHint = findViewById(R.id.shared_secret_hint); + this.mManualVerificationArea = findViewById(R.id.manual_verification_area); + this.mSmpVerificationArea = findViewById(R.id.smp_verification_area); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.verify_otr, menu); + return true; + } + + private void showManuallyVerifyDialog() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.manually_verify); + builder.setMessage(R.string.are_you_sure_verify_fingerprint); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.verify, mVerifyFingerprintListener); + builder.create().show(); + } + + @Override + protected String getShareableUri() { + if (mAccount != null) { + return mAccount.getShareableUri(); + } else { + return ""; + } + } + + public void onConversationUpdate() { + refreshUi(); + } + + @Override + protected void refreshUiReal() { + updateView(); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index 9c569abb7..c35304c78 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -3,6 +3,12 @@ package eu.siacs.conversations.ui; import static eu.siacs.conversations.ui.SettingsActivity.USE_BUNDLED_EMOJIS; import static eu.siacs.conversations.ui.SettingsActivity.USE_INTERNAL_UPDATER; +import android.util.Pair; +import net.java.otr4j.session.SessionID; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import eu.siacs.conversations.utils.CryptoHelper; import android.Manifest; import android.annotation.SuppressLint; import android.annotation.TargetApi; @@ -106,6 +112,8 @@ import eu.siacs.conversations.xmpp.XmppConnection; import me.drakeet.support.toast.ToastCompat; import pl.droidsonroids.gif.GifDrawable; +import static eu.siacs.conversations.ui.SettingsActivity.ENABLE_OTR_ENCRYPTION; + public abstract class XmppActivity extends ActionBarActivity { protected static final int REQUEST_ANNOUNCE_PGP = 0x0101; @@ -419,6 +427,19 @@ public abstract class XmppActivity extends ActionBarActivity { public void selectPresence(final Conversation conversation, final PresenceSelector.OnPresenceSelected listener) { final Contact contact = conversation.getContact(); + if (conversation.hasValidOtrSession()) { + SessionID id = conversation.getOtrSession().getSessionID(); + Jid jid; + try { + jid = Jid.of(id.getAccountID() + "/" + id.getUserID()); + } catch (IllegalArgumentException e) { + jid = null; + } + conversation.setNextCounterpart(jid); + listener.onPresenceSelected(); + } else if (!contact.showInRoster()) { + showAddToRosterDialog(conversation); + } if (contact.showInRoster() || contact.isSelf()) { final Presences presences = contact.getPresences(); if (presences.size() == 0) { @@ -489,7 +510,9 @@ public abstract class XmppActivity extends ActionBarActivity { public boolean unicoloredBG() { return getBooleanPreference("unicolored_chatbg", R.bool.use_unicolored_chatbg) || getPreferences().getString(SettingsActivity.THEME, getString(R.string.theme)).equals("black"); } - + public boolean enableOTR() { + return getBooleanPreference(ENABLE_OTR_ENCRYPTION, R.bool.enable_otr); + } public boolean showDateInQuotes() { return getBooleanPreference("show_date_in_quotes", R.bool.show_date_in_quotes); } @@ -909,7 +932,58 @@ public abstract class XmppActivity extends ActionBarActivity { } } - protected void onActivityResult(int requestCode, int resultCode, final Intent data) { + private void showPresenceSelectionDialog(Presences presences, final Conversation conversation, final OnPresenceSelected listener) { + final Contact contact = conversation.getContact(); + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.choose_presence)); + final String[] resourceArray = presences.toResourceArray(); + Pair<Map<String, String>, Map<String, String>> typeAndName = presences.toTypeAndNameMap(); + final Map<String, String> resourceTypeMap = typeAndName.first; + final Map<String, String> resourceNameMap = typeAndName.second; + final String[] readableIdentities = new String[resourceArray.length]; + final AtomicInteger selectedResource = new AtomicInteger(0); + for (int i = 0; i < resourceArray.length; ++i) { + String resource = resourceArray[i]; + if (resource.equals(contact.getLastResource())) { + selectedResource.set(i); + } + String type = resourceTypeMap.get(resource); + String name = resourceNameMap.get(resource); + if (type != null) { + if (Collections.frequency(resourceTypeMap.values(), type) == 1) { + readableIdentities[i] = PresenceSelector.translateType(this, type); + } else if (name != null) { + if (Collections.frequency(resourceNameMap.values(), name) == 1 + || CryptoHelper.UUID_PATTERN.matcher(resource).matches()) { + readableIdentities[i] = PresenceSelector.translateType(this, type) + " (" + name + ")"; + } else { + readableIdentities[i] = PresenceSelector.translateType(this, type) + " (" + name + " / " + resource + ")"; + } + } else { + readableIdentities[i] = PresenceSelector.translateType(this, type) + " (" + resource + ")"; + } + } else { + readableIdentities[i] = resource; + } + } + builder.setSingleChoiceItems(readableIdentities, + selectedResource.get(), + (dialog, which) -> selectedResource.set(which)); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ok, (dialog, which) -> { + try { + Jid next = Jid.of(contact.getJid().getLocal(), contact.getJid().getDomain(), resourceArray[selectedResource.get()]); + conversation.setNextCounterpart(next); + } catch (IllegalArgumentException e) { + conversation.setNextCounterpart(null); + } + listener.onPresenceSelected(); + }); + builder.create().show(); + } + + + protected void onActivityResult(int requestCode, int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_INVITE_TO_CONVERSATION && resultCode == RESULT_OK) { mPendingConferenceInvite = ConferenceInvite.parse(data); diff --git a/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java b/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java index 59a94a508..fb3706773 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java +++ b/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java @@ -132,6 +132,7 @@ public class ConversationMenuConfigurator { if (conversation.getNextEncryption() != Message.ENCRYPTION_NONE) { menuSecure.setIcon(R.drawable.ic_lock_white_24dp); } + otr.setVisible(Config.supportOtr() && activity.enableOTR()); if (conversation.getMode() == Conversation.MODE_MULTI) { otr.setVisible(false); } diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index 58edbef5b..6288c81e1 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -594,6 +594,8 @@ public class UIHelper { } else { return context.getString(R.string.send_message_to_x, conversation.getName()); } + case Message.ENCRYPTION_OTR: + return context.getString(R.string.send_otr_message); case Message.ENCRYPTION_AXOLOTL: AxolotlService axolotlService = conversation.getAccount().getAxolotlService(); if (axolotlService != null && axolotlService.trustedSessionVerified(conversation)) { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java index 8d3d8ead9..09c4dec53 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java @@ -1,9 +1,17 @@ + package eu.siacs.conversations.xmpp.jid; +import net.java.otr4j.session.SessionID; import eu.siacs.conversations.xmpp.Jid; public final class OtrJidHelper { - + public static Jid fromSessionID(final SessionID id) throws IllegalArgumentException { + if (id.getUserID().isEmpty()) { + return Jid.of(id.getAccountID()); + } else { + return Jid.of(id.getAccountID() + "/" + id.getUserID()); + } + } } \ No newline at end of file diff --git a/src/main/res/layout/activity_edit_account.xml b/src/main/res/layout/activity_edit_account.xml index be16aa780..98ef4e5f3 100644 --- a/src/main/res/layout/activity_edit_account.xml +++ b/src/main/res/layout/activity_edit_account.xml @@ -758,6 +758,57 @@ </LinearLayout> </RelativeLayout> + <RelativeLayout + android:id="@+id/otr_fingerprint_box" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_marginTop="24dp"> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentStart="true" + android:layout_alignParentLeft="true" + android:layout_centerVertical="true" + android:layout_toStartOf="@+id/key_actions" + android:layout_toLeftOf="@+id/key_actions" + android:orientation="vertical"> + + <TextView + android:id="@+id/otr_fingerprint" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Conversations.Fingerprint" /> + + <TextView + android:id="@+id/otr_fingerprint_desc" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/otr_fingerprint" + android:textAppearance="@style/TextAppearance.Conversations.Caption" /> + + </LinearLayout> + + <LinearLayout + android:id="@+id/key_actions" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentEnd="true" + android:layout_alignParentRight="true" + android:layout_centerVertical="true" + android:orientation="horizontal"> + + <ImageButton + android:id="@+id/action_copy_to_clipboard" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/copy_otr_clipboard_description" + android:padding="@dimen/image_button_padding" + android:src="?attr/icon_copy" + android:visibility="visible" /> + </LinearLayout> + </RelativeLayout> <RelativeLayout android:id="@+id/axolotl_fingerprint_box" android:layout_width="wrap_content" diff --git a/src/main/res/menu/verification_choices.xml b/src/main/res/menu/verification_choices.xml index 57d45dcde..cad8dee9a 100644 --- a/src/main/res/menu/verification_choices.xml +++ b/src/main/res/menu/verification_choices.xml @@ -1,9 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android"> - <item - android:id="@+id/scan_fingerprint" - android:title="@string/scan_qr_code" /> <item android:id="@+id/ask_question" android:title="@string/ask_question" /> diff --git a/src/main/res/values/about.xml b/src/main/res/values/about.xml index 0d82c88ec..804430a6e 100644 --- a/src/main/res/values/about.xml +++ b/src/main/res/values/about.xml @@ -46,6 +46,7 @@ \n\nhttp://leafletjs.com/\n(BSD 2-Clause) \n\nhttps://www.openstreetmap.org/\n(Open Database License) \n\nhttp://xmpp.rocks/\n(The MIT License (MIT)) + \n\nhttps://github.com/jitsi/otr4j\n(LGPL-3.0) \n\nhttps://github.com/drakeet/ToastCompat/\n(Apache License, Version 2.0) \n\nhttps://github.com/leinardi/FloatingActionButtonSpeedDial/\n(Apache License, Version 2.0) \n\nhttps://github.com/PonnamKarthik/RichLinkPreview\n(Apache License, Version 2.0) diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index 7ff48ca5d..b72e9bcbc 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -63,6 +63,7 @@ <bool name="use_internal_updater">true</bool> <bool name="show_own_accounts">true</bool> <bool name="vibrate_in_chat">true</bool> + <bool name="enable_otr">false</bool> <bool name="showtextformatting">true</bool> <integer name="auto_accept_filesize_wifi">10485760</integer> <integer name="auto_accept_filesize_mobile">524288</integer> diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 8e7d4141a..f76efe109 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -836,6 +836,19 @@ <string name="pref_play_gif_inside">Play GIF files in chat</string> <string name="pref_play_gif_inside_summary">Play GIF files directly inside the chat view.</string> <string name="open_with">Open with…</string> + <string name="send_otr_message">Send OTR encrypted message</string> + <string name="unknown_otr_fingerprint">Unknown OTR fingerprint</string> + <string name="otr_fingerprint">OTR fingerprint</string> + <string name="otr_fingerprint_selected_message">OTR fingerprint of message</string> + <string name="toast_message_otr_fingerprint">OTR fingerprint copied to clipboard!</string> + <string name="verify_otr">Verify OTR</string> + <string name="no_otr_session_found">No valid OTR session has been found!</string> + <string name="are_you_sure_verify_fingerprint">Are you sure that you want to verify your contacts OTR fingerprint?</string> + <string name="copy_otr_clipboard_description">Copy OTR fingerprint to clipboard</string> + <string name="otr_session_not_started">Send a message to start an encrypted chat</string> + <string name="pref_enable_otr_summary">Enable OTR encryption for message encryption</string> + <string name="pref_enable_otr">Enable OTR encryption</string> + <string name="otr_warning">The support of OTR encryption is in the beta mode. Click read more to get more information. A link in a browser will open.</string> <string name="server_info_adhoc_invite">XEP-0050: Ad-Hoc Commands: user invite</string> <string name="choose_account">Choose account</string> <string name="set_profile_picture">monocles chat account picture</string> diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index c7b642457..4386c6875 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -489,6 +489,11 @@ android:key="delete_omemo_identities" android:summary="@string/pref_delete_omemo_identities_summary" android:title="@string/pref_delete_omemo_identities" /> + <CheckBoxPreference + android:defaultValue="@bool/enable_otr" + android:key="enable_otr_encryption" + android:summary="@string/pref_enable_otr_summary" + android:title="@string/pref_enable_otr" /> <CheckBoxPreference android:defaultValue="@bool/dont_trust_system_cas" android:key="dont_trust_system_cas"