Introduce XmppConnection v3

The various layers of the app are too intertwined to refactor them in place.

The C3 refactor is going to create a parallel architecture for all classes that
have too strong of a connection to other parts of the app.

This commit introduces XmppConnection v3 that keeps a lot of the logic of the
privous XmppConnection but cuts ties to XmppConnectionService and the very
stateful `entites.Account`. The latter is replaced by a lightweight immutable
account model.

The reconnection logic has been kept but was moved from XmppConnectionService
to a singleton ConnectionPool.
This commit is contained in:
Daniel Gultsch 2023-01-13 10:59:23 +01:00
parent 94dde9f433
commit 7ee3e07946
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
45 changed files with 5671 additions and 147 deletions

View file

@ -53,6 +53,8 @@ dependencies {
annotationProcessor "androidx.room:room-compiler:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-guava:$room_version" implementation "androidx.room:room-guava:$room_version"
implementation "androidx.security:security-crypto:1.0.0"
// legacy dependencies. Ideally everything below should be carefully reviewed and eventually moved up // legacy dependencies. Ideally everything below should be carefully reviewed and eventually moved up

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "c78cb993428558b863fd91c46b608926", "identityHash": "4a70ff0733436f5a2a08e7abb8e6cc95",
"entities": [ "entities": [
{ {
"tableName": "account", "tableName": "account",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `address` TEXT NOT NULL, `resource` TEXT, `randomSeed` BLOB, `enabled` INTEGER NOT NULL, `rosterVersion` TEXT, `hostname` TEXT, `port` INTEGER, `directTls` INTEGER, `proxytype` TEXT, `proxyhostname` TEXT, `proxyport` INTEGER)", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `address` TEXT NOT NULL, `resource` TEXT, `randomSeed` BLOB, `enabled` INTEGER NOT NULL, `quickStartAvailable` INTEGER NOT NULL, `pendingRegistration` INTEGER NOT NULL, `loggedInSuccessfully` INTEGER NOT NULL, `showErrorNotification` INTEGER NOT NULL, `rosterVersion` TEXT, `hostname` TEXT, `port` INTEGER, `directTls` INTEGER, `proxytype` TEXT, `proxyhostname` TEXT, `proxyport` INTEGER)",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -38,6 +38,30 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "quickStartAvailable",
"columnName": "quickStartAvailable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pendingRegistration",
"columnName": "pendingRegistration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "loggedInSuccessfully",
"columnName": "loggedInSuccessfully",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showErrorNotification",
"columnName": "showErrorNotification",
"affinity": "INTEGER",
"notNull": true
},
{ {
"fieldPath": "rosterVersion", "fieldPath": "rosterVersion",
"columnName": "rosterVersion", "columnName": "rosterVersion",
@ -830,7 +854,7 @@
}, },
{ {
"tableName": "presence", "tableName": "presence",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `resource` TEXT, `type` TEXT, `show` TEXT, `status` TEXT, `vCardPhoto` TEXT, `occupantId` TEXT, `mucUserAffiliation` TEXT, `mucUserRole` TEXT, `mucUserJid` TEXT, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `resource` TEXT, `type` TEXT, `show` TEXT, `status` TEXT, `vCardPhoto` TEXT, `occupantId` TEXT, `mucUserAffiliation` TEXT, `mucUserRole` TEXT, `mucUserJid` TEXT, `mucUserSelf` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -903,6 +927,12 @@
"columnName": "mucUserJid", "columnName": "mucUserJid",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
},
{
"fieldPath": "mucUserSelf",
"columnName": "mucUserSelf",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@ -1159,7 +1189,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c78cb993428558b863fd91c46b608926')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4a70ff0733436f5a2a08e7abb8e6cc95')"
] ]
} }
} }

View file

@ -74,6 +74,7 @@
<application <application
android:name="im.conversations.android.Conversations"
android:allowBackup="true" android:allowBackup="true"
android:appCategory="social" android:appCategory="social"
android:fullBackupContent="@xml/backup_content" android:fullBackupContent="@xml/backup_content"

View file

@ -38,18 +38,18 @@ import android.preference.PreferenceManager;
import android.util.Base64; import android.util.Base64;
import android.util.Log; import android.util.Log;
import android.util.SparseArray; import android.util.SparseArray;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import com.google.common.base.Charsets; import com.google.common.base.Charsets;
import com.google.common.base.Joiner; import com.google.common.base.Joiner;
import com.google.common.io.ByteStreams; import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams; import com.google.common.io.CharStreams;
import eu.siacs.conversations.Config;
import org.json.JSONArray; import eu.siacs.conversations.R;
import org.json.JSONException; import eu.siacs.conversations.crypto.XmppDomainVerifier;
import org.json.JSONObject; import eu.siacs.conversations.entities.MTMDecision;
import eu.siacs.conversations.http.HttpConnectionManager;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.ui.MemorizingActivity;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@ -73,44 +73,48 @@ import java.util.Locale;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager; import javax.net.ssl.X509TrustManager;
import org.json.JSONArray;
import eu.siacs.conversations.Config; import org.json.JSONException;
import eu.siacs.conversations.R; import org.json.JSONObject;
import eu.siacs.conversations.crypto.XmppDomainVerifier;
import eu.siacs.conversations.entities.MTMDecision;
import eu.siacs.conversations.http.HttpConnectionManager;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.ui.MemorizingActivity;
/** /**
* A X509 trust manager implementation which asks the user about invalid * A X509 trust manager implementation which asks the user about invalid certificates and memorizes
* certificates and memorizes their decision. * their decision.
* <p> *
* The certificate validity is checked using the system default X509 * <p>The certificate validity is checked using the system default X509 TrustManager, creating a
* TrustManager, creating a query Dialog if the check fails. * query Dialog if the check fails.
* <p> *
* <b>WARNING:</b> This only works if a dedicated thread is used for * <p><b>WARNING:</b> This only works if a dedicated thread is used for opening sockets!
* opening sockets!
*/ */
public class MemorizingTrustManager { public class MemorizingTrustManager {
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); private static final SimpleDateFormat DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd", Locale.US);
final static String DECISION_INTENT = "de.duenndns.ssl.DECISION"; static final String DECISION_INTENT = "de.duenndns.ssl.DECISION";
public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; public static final String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId";
public final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; public static final String DECISION_INTENT_CERT = DECISION_INTENT + ".cert";
public final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId"; public static final String DECISION_TITLE_ID = DECISION_INTENT + ".titleId";
final static String NO_TRUST_ANCHOR = "Trust anchor for certification path not found."; static final String NO_TRUST_ANCHOR = "Trust anchor for certification path not found.";
private static final Pattern PATTERN_IPV4 = Pattern.compile("\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); private static final Pattern PATTERN_IPV4 =
private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); Pattern.compile(
private static final Pattern PATTERN_IPV6_6HEX4DEC = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z"); private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED =
private static final Pattern PATTERN_IPV6 = Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z"); Pattern.compile(
private final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName()); "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)"
+ " ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
private static final Pattern PATTERN_IPV6_6HEX4DEC =
Pattern.compile(
"\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
private static final Pattern PATTERN_IPV6_HEXCOMPRESSED =
Pattern.compile(
"\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z");
private static final Pattern PATTERN_IPV6 =
Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z");
private static final Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName());
static String KEYSTORE_DIR = "KeyStore"; static String KEYSTORE_DIR = "KeyStore";
static String KEYSTORE_FILE = "KeyStore.bks"; static String KEYSTORE_FILE = "KeyStore.bks";
private static int decisionId = 0; private static int decisionId = 0;
@ -125,19 +129,32 @@ public class MemorizingTrustManager {
private X509TrustManager appTrustManager; private X509TrustManager appTrustManager;
private String poshCacheDir; private String poshCacheDir;
public static MemorizingTrustManager create(final Context context) {
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
final boolean dontTrustSystemCAs =
preferences.getBoolean(
"dont_trust_system_cas",
context.getResources().getBoolean(R.bool.dont_trust_system_cas));
if (dontTrustSystemCAs) {
return new MemorizingTrustManager(context.getApplicationContext(), null);
} else {
return new MemorizingTrustManager(context.getApplicationContext());
}
}
/** /**
* Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager. * Creates an instance of the MemorizingTrustManager class that falls back to a custom
* <p> * TrustManager.
* You need to supply the application context. This has to be one of: *
* - Application * <p>You need to supply the application context. This has to be one of: - Application -
* - Activity * Activity - Service
* - Service *
* <p> * <p>The context is used for file management, to display the dialog / notification and for
* The context is used for file management, to display the dialog / * obtaining translated strings.
* notification and for obtaining translated strings.
* *
* @param m Context for the application. * @param m Context for the application.
* @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate. * @param defaultTrustManager Delegate trust management to this TM. If null, the user must
* accept every certificate.
*/ */
public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) { public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) {
init(m); init(m);
@ -147,14 +164,12 @@ public class MemorizingTrustManager {
/** /**
* Creates an instance of the MemorizingTrustManager class using the system X509TrustManager. * Creates an instance of the MemorizingTrustManager class using the system X509TrustManager.
* <p> *
* You need to supply the application context. This has to be one of: * <p>You need to supply the application context. This has to be one of: - Application -
* - Application * Activity - Service
* - Activity *
* - Service * <p>The context is used for file management, to display the dialog / notification and for
* <p> * obtaining translated strings.
* The context is used for file management, to display the dialog /
* notification and for obtaining translated strings.
* *
* @param m Context for the application. * @param m Context for the application.
*/ */
@ -165,15 +180,16 @@ public class MemorizingTrustManager {
} }
private static boolean isIp(final String server) { private static boolean isIp(final String server) {
return server != null && ( return server != null
PATTERN_IPV4.matcher(server).matches() && (PATTERN_IPV4.matcher(server).matches()
|| PATTERN_IPV6.matcher(server).matches() || PATTERN_IPV6.matcher(server).matches()
|| PATTERN_IPV6_6HEX4DEC.matcher(server).matches() || PATTERN_IPV6_6HEX4DEC.matcher(server).matches()
|| PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches() || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches()
|| PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches()); || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches());
} }
private static String getBase64Hash(X509Certificate certificate, String digest) throws CertificateEncodingException { private static String getBase64Hash(X509Certificate certificate, String digest)
throws CertificateEncodingException {
MessageDigest md; MessageDigest md;
try { try {
md = MessageDigest.getInstance(digest); md = MessageDigest.getInstance(digest);
@ -188,8 +204,7 @@ public class MemorizingTrustManager {
StringBuffer si = new StringBuffer(); StringBuffer si = new StringBuffer();
for (int i = 0; i < data.length; i++) { for (int i = 0; i < data.length; i++) {
si.append(String.format("%02x", data[i])); si.append(String.format("%02x", data[i]));
if (i < data.length - 1) if (i < data.length - 1) si.append(":");
si.append(":");
} }
return si.toString(); return si.toString();
} }
@ -223,7 +238,8 @@ public class MemorizingTrustManager {
void init(final Context m) { void init(final Context m) {
master = m; master = m;
masterHandler = new Handler(m.getMainLooper()); masterHandler = new Handler(m.getMainLooper());
notificationManager = (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager =
(NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE);
Application app; Application app;
if (m instanceof Application) { if (m instanceof Application) {
@ -233,7 +249,8 @@ public class MemorizingTrustManager {
} else if (m instanceof AppCompatActivity) { } else if (m instanceof AppCompatActivity) {
app = ((AppCompatActivity) m).getApplication(); app = ((AppCompatActivity) m).getApplication();
} else } else
throw new ClassCastException("MemorizingTrustManager context must be either Activity or Service!"); throw new ClassCastException(
"MemorizingTrustManager context must be either Activity or Service!");
File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE); File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE);
keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE); keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE);
@ -260,12 +277,9 @@ public class MemorizingTrustManager {
/** /**
* Removes the given certificate from MTMs key store. * Removes the given certificate from MTMs key store.
* *
* <p> * <p><b>WARNING</b>: this does not immediately invalidate the certificate. It is well possible
* <b>WARNING</b>: this does not immediately invalidate the certificate. It is * that (a) data is transmitted over still existing connections or (b) new connections are
* well possible that (a) data is transmitted over still existing connections or * created using TLS renegotiation, without a new cert check.
* (b) new connections are created using TLS renegotiation, without a new cert
* check.
* </p>
* *
* @param alias the certificate's alias as returned by {@link #getCertificates()}. * @param alias the certificate's alias as returned by {@link #getCertificates()}.
* @throws KeyStoreException if the certificate could not be deleted. * @throws KeyStoreException if the certificate could not be deleted.
@ -361,45 +375,60 @@ public class MemorizingTrustManager {
} }
} }
private void checkCertTrusted(
private void checkCertTrusted(X509Certificate[] chain, String authType, String domain, boolean isServer, boolean interactive) X509Certificate[] chain,
String authType,
String domain,
boolean isServer,
boolean interactive)
throws CertificateException { throws CertificateException {
LOGGER.log(Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")"); LOGGER.log(
Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")");
try { try {
LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager"); LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager");
if (isServer) if (isServer) appTrustManager.checkServerTrusted(chain, authType);
appTrustManager.checkServerTrusted(chain, authType); else appTrustManager.checkClientTrusted(chain, authType);
else
appTrustManager.checkClientTrusted(chain, authType);
} catch (final CertificateException ae) { } catch (final CertificateException ae) {
LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae); LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae);
if (isCertKnown(chain[0])) { if (isCertKnown(chain[0])) {
LOGGER.log(Level.INFO, "checkCertTrusted: accepting cert already stored in keystore"); LOGGER.log(
Level.INFO, "checkCertTrusted: accepting cert already stored in keystore");
return; return;
} }
try { try {
if (defaultTrustManager == null) if (defaultTrustManager == null) throw ae;
throw ae;
LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager"); LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager");
if (isServer) if (isServer) defaultTrustManager.checkServerTrusted(chain, authType);
defaultTrustManager.checkServerTrusted(chain, authType); else defaultTrustManager.checkClientTrusted(chain, authType);
else
defaultTrustManager.checkClientTrusted(chain, authType);
} catch (final CertificateException e) { } catch (final CertificateException e) {
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(master); final SharedPreferences preferences =
final boolean trustSystemCAs = !preferences.getBoolean("dont_trust_system_cas", false); PreferenceManager.getDefaultSharedPreferences(master);
if (domain != null && isServer && trustSystemCAs && !isIp(domain) && !domain.endsWith(".onion")) { final boolean trustSystemCAs =
!preferences.getBoolean("dont_trust_system_cas", false);
if (domain != null
&& isServer
&& trustSystemCAs
&& !isIp(domain)
&& !domain.endsWith(".onion")) {
final String hash = getBase64Hash(chain[0], "SHA-256"); final String hash = getBase64Hash(chain[0], "SHA-256");
final List<String> fingerprints = getPoshFingerprints(domain); final List<String> fingerprints = getPoshFingerprints(domain);
if (hash != null && fingerprints.size() > 0) { if (hash != null && fingerprints.size() > 0) {
if (fingerprints.contains(hash)) { if (fingerprints.contains(hash)) {
Log.d(Config.LOGTAG, "trusted cert fingerprint of " + domain + " via posh"); Log.d(
Config.LOGTAG,
"trusted cert fingerprint of " + domain + " via posh");
return; return;
} else { } else {
Log.d(Config.LOGTAG, "fingerprint " + hash + " not found in " + fingerprints); Log.d(
Config.LOGTAG,
"fingerprint " + hash + " not found in " + fingerprints);
} }
if (getPoshCacheFile(domain).delete()) { if (getPoshCacheFile(domain).delete()) {
Log.d(Config.LOGTAG, "deleted posh file for " + domain + " after not being able to verify"); Log.d(
Config.LOGTAG,
"deleted posh file for "
+ domain
+ " after not being able to verify");
} }
} }
} }
@ -422,17 +451,25 @@ public class MemorizingTrustManager {
} }
private List<String> getPoshFingerprintsFromServer(String domain) { private List<String> getPoshFingerprintsFromServer(String domain) {
return getPoshFingerprintsFromServer(domain, "https://" + domain + "/.well-known/posh/xmpp-client.json", -1, true); return getPoshFingerprintsFromServer(
domain, "https://" + domain + "/.well-known/posh/xmpp-client.json", -1, true);
} }
private List<String> getPoshFingerprintsFromServer(String domain, String url, int maxTtl, boolean followUrl) { private List<String> getPoshFingerprintsFromServer(
String domain, String url, int maxTtl, boolean followUrl) {
Log.d(Config.LOGTAG, "downloading json for " + domain + " from " + url); Log.d(Config.LOGTAG, "downloading json for " + domain + " from " + url);
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(master); final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(master);
final boolean useTor = QuickConversationsService.isConversations() && preferences.getBoolean("use_tor", master.getResources().getBoolean(R.bool.use_tor)); final boolean useTor =
QuickConversationsService.isConversations()
&& preferences.getBoolean(
"use_tor", master.getResources().getBoolean(R.bool.use_tor));
try { try {
final List<String> results = new ArrayList<>(); final List<String> results = new ArrayList<>();
final InputStream inputStream = HttpConnectionManager.open(url, useTor); final InputStream inputStream = HttpConnectionManager.open(url, useTor);
final String body = CharStreams.toString(new InputStreamReader(ByteStreams.limit(inputStream,10_000), Charsets.UTF_8)); final String body =
CharStreams.toString(
new InputStreamReader(
ByteStreams.limit(inputStream, 10_000), Charsets.UTF_8));
final JSONObject jsonObject = new JSONObject(body); final JSONObject jsonObject = new JSONObject(body);
int expires = jsonObject.getInt("expires"); int expires = jsonObject.getInt("expires");
if (expires <= 0) { if (expires <= 0) {
@ -489,7 +526,8 @@ public class MemorizingTrustManager {
final File file = getPoshCacheFile(domain); final File file = getPoshCacheFile(domain);
try { try {
final InputStream inputStream = new FileInputStream(file); final InputStream inputStream = new FileInputStream(file);
final String json = CharStreams.toString(new InputStreamReader(inputStream, Charsets.UTF_8)); final String json =
CharStreams.toString(new InputStreamReader(inputStream, Charsets.UTF_8));
final JSONObject jsonObject = new JSONObject(json); final JSONObject jsonObject = new JSONObject(json);
long expires = jsonObject.getLong("expires"); long expires = jsonObject.getLong("expires");
long expiresIn = expires - System.currentTimeMillis(); long expiresIn = expires - System.currentTimeMillis();
@ -514,7 +552,9 @@ public class MemorizingTrustManager {
} }
private X509Certificate[] getAcceptedIssuers() { private X509Certificate[] getAcceptedIssuers() {
return defaultTrustManager == null ? new X509Certificate[0] : defaultTrustManager.getAcceptedIssuers(); return defaultTrustManager == null
? new X509Certificate[0]
: defaultTrustManager.getAcceptedIssuers();
} }
private int createDecisionId(MTMDecision d) { private int createDecisionId(MTMDecision d) {
@ -527,7 +567,8 @@ public class MemorizingTrustManager {
return myId; return myId;
} }
private void certDetails(final StringBuffer si, final X509Certificate c, final boolean showValidFor) { private void certDetails(
final StringBuffer si, final X509Certificate c, final boolean showValidFor) {
si.append("\n"); si.append("\n");
if (showValidFor) { if (showValidFor) {
@ -564,8 +605,7 @@ public class MemorizingTrustManager {
// not found", so we use string comparison. // not found", so we use string comparison.
if (NO_TRUST_ANCHOR.equals(e.getMessage())) { if (NO_TRUST_ANCHOR.equals(e.getMessage())) {
si.append(master.getString(R.string.mtm_trust_anchor)); si.append(master.getString(R.string.mtm_trust_anchor));
} else } else si.append(e.getLocalizedMessage());
si.append(e.getLocalizedMessage());
si.append("\n"); si.append("\n");
} }
si.append("\n"); si.append("\n");
@ -593,7 +633,8 @@ public class MemorizingTrustManager {
MTMDecision choice = new MTMDecision(); MTMDecision choice = new MTMDecision();
final int myId = createDecisionId(choice); final int myId = createDecisionId(choice);
masterHandler.post(new Runnable() { masterHandler.post(
new Runnable() {
public void run() { public void run() {
Intent ni = new Intent(master, MemorizingActivity.class); Intent ni = new Intent(master, MemorizingActivity.class);
ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@ -661,7 +702,8 @@ public class MemorizingTrustManager {
} }
@Override @Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, false); MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, false);
} }
@ -675,7 +717,6 @@ public class MemorizingTrustManager {
public X509Certificate[] getAcceptedIssuers() { public X509Certificate[] getAcceptedIssuers() {
return MemorizingTrustManager.this.getAcceptedIssuers(); return MemorizingTrustManager.this.getAcceptedIssuers();
} }
} }
private class InteractiveMemorizingTrustManager implements X509TrustManager { private class InteractiveMemorizingTrustManager implements X509TrustManager {
@ -686,7 +727,8 @@ public class MemorizingTrustManager {
} }
@Override @Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, true); MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, true);
} }

View file

@ -2,13 +2,11 @@ package eu.siacs.conversations.utils;
import android.os.Build; import android.os.Build;
import android.util.Log; import android.util.Log;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import eu.siacs.conversations.Config;
import org.conscrypt.Conscrypt; import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.xmpp.Jid;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.net.Socket; import java.net.Socket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -17,29 +15,26 @@ import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import javax.net.ssl.SNIHostName; import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocket;
import org.conscrypt.Conscrypt;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Account;
public class SSLSockets { public class SSLSockets {
public static void setSecurity(final SSLSocket sslSocket) { public static void setSecurity(final SSLSocket sslSocket) {
final String[] supportProtocols; final String[] supportProtocols;
final Collection<String> supportedProtocols = new LinkedList<>( final Collection<String> supportedProtocols =
Arrays.asList(sslSocket.getSupportedProtocols())); new LinkedList<>(Arrays.asList(sslSocket.getSupportedProtocols()));
supportedProtocols.remove("SSLv3"); supportedProtocols.remove("SSLv3");
supportProtocols = supportedProtocols.toArray(new String[0]); supportProtocols = supportedProtocols.toArray(new String[0]);
sslSocket.setEnabledProtocols(supportProtocols); sslSocket.setEnabledProtocols(supportProtocols);
final String[] cipherSuites = CryptoHelper.getOrderedCipherSuites( final String[] cipherSuites =
sslSocket.getSupportedCipherSuites()); CryptoHelper.getOrderedCipherSuites(sslSocket.getSupportedCipherSuites());
if (cipherSuites.length > 0) { if (cipherSuites.length > 0) {
sslSocket.setEnabledCipherSuites(cipherSuites); sslSocket.setEnabledCipherSuites(cipherSuites);
} }
@ -70,7 +65,8 @@ public class SSLSockets {
socket.setSSLParameters(parameters); socket.setSSLParameters(parameters);
} }
private static void setApplicationProtocolReflection(final SSLSocket socket, final String protocol) { private static void setApplicationProtocolReflection(
final SSLSocket socket, final String protocol) {
try { try {
final Method method = socket.getClass().getMethod("setAlpnProtocols", byte[].class); final Method method = socket.getClass().getMethod("setAlpnProtocols", byte[].class);
// the concatenation of 8-bit, length prefixed protocol names, just one in our case... // the concatenation of 8-bit, length prefixed protocol names, just one in our case...
@ -78,7 +74,8 @@ public class SSLSockets {
final byte[] protocolUTF8Bytes = protocol.getBytes(StandardCharsets.UTF_8); final byte[] protocolUTF8Bytes = protocol.getBytes(StandardCharsets.UTF_8);
final byte[] lengthPrefixedProtocols = new byte[protocolUTF8Bytes.length + 1]; final byte[] lengthPrefixedProtocols = new byte[protocolUTF8Bytes.length + 1];
lengthPrefixedProtocols[0] = (byte) protocol.length(); // cannot be over 255 anyhow lengthPrefixedProtocols[0] = (byte) protocol.length(); // cannot be over 255 anyhow
System.arraycopy(protocolUTF8Bytes, 0, lengthPrefixedProtocols, 1, protocolUTF8Bytes.length); System.arraycopy(
protocolUTF8Bytes, 0, lengthPrefixedProtocols, 1, protocolUTF8Bytes.length);
method.invoke(socket, new Object[] {lengthPrefixedProtocols}); method.invoke(socket, new Object[] {lengthPrefixedProtocols});
} catch (Throwable e) { } catch (Throwable e) {
Log.e(Config.LOGTAG, "unable to set ALPN on socket", e); Log.e(Config.LOGTAG, "unable to set ALPN on socket", e);
@ -101,11 +98,15 @@ public class SSLSockets {
} }
} }
public static void log(Account account, SSLSocket socket) { public static void log(final Account account, SSLSocket socket) {
log(account.getJid(), socket);
}
public static void log(final Jid address, SSLSocket socket) {
SSLSession session = socket.getSession(); SSLSession session = socket.getSession();
Log.d( Log.d(
Config.LOGTAG, Config.LOGTAG,
account.getJid().asBareJid() address
+ ": protocol=" + ": protocol="
+ session.getProtocol() + session.getProtocol()
+ " cipher=" + " cipher="

View file

@ -1,12 +1,11 @@
package eu.siacs.conversations.xml; package eu.siacs.conversations.xml;
import org.jetbrains.annotations.NotNull; import eu.siacs.conversations.utils.XmlHelper;
import eu.siacs.conversations.xmpp.Jid;
import java.util.Hashtable; import java.util.Hashtable;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Set; import java.util.Set;
import org.jetbrains.annotations.NotNull;
import eu.siacs.conversations.utils.XmlHelper;
public class Tag { public class Tag {
public static final int NO = -1; public static final int NO = -1;
@ -52,6 +51,13 @@ public class Tag {
return this; return this;
} }
public Tag setAttribute(final String attrName, final Jid attrValue) {
if (attrValue != null) {
this.attributes.put(attrName, attrValue.toEscapedString());
}
return this;
}
public void setAttributes(final Hashtable<String, String> attributes) { public void setAttributes(final Hashtable<String, String> attributes) {
this.attributes = attributes; this.attributes = attributes;
} }

View file

@ -0,0 +1,13 @@
package im.conversations.android;
import android.app.Application;
import im.conversations.android.xmpp.ConnectionPool;
public class Conversations extends Application {
@Override
public void onCreate() {
super.onCreate();
ConnectionPool.getInstance(this).reconfigure();
}
}

View file

@ -0,0 +1,38 @@
package im.conversations.android;
import com.google.common.base.Preconditions;
import java.util.UUID;
public class Uuids {
private static final long VERSION_MASK = 4 << 12;
public static UUID getUuid(final byte[] bytes) {
Preconditions.checkArgument(bytes != null && bytes.length == 32);
long msb = 0;
long lsb = 0;
msb |= (bytes[0x0] & 0xffL) << 56;
msb |= (bytes[0x1] & 0xffL) << 48;
msb |= (bytes[0x2] & 0xffL) << 40;
msb |= (bytes[0x3] & 0xffL) << 32;
msb |= (bytes[0x4] & 0xffL) << 24;
msb |= (bytes[0x5] & 0xffL) << 16;
msb |= (bytes[0x6] & 0xffL) << 8;
msb |= (bytes[0x7] & 0xffL);
lsb |= (bytes[0x8] & 0xffL) << 56;
lsb |= (bytes[0x9] & 0xffL) << 48;
lsb |= (bytes[0xa] & 0xffL) << 40;
lsb |= (bytes[0xb] & 0xffL) << 32;
lsb |= (bytes[0xc] & 0xffL) << 24;
lsb |= (bytes[0xd] & 0xffL) << 16;
lsb |= (bytes[0xe] & 0xffL) << 8;
lsb |= (bytes[0xf] & 0xffL);
msb = (msb & 0xffffffffffff0fffL) | VERSION_MASK; // set version
lsb = (lsb & 0x3fffffffffffffffL) | 0x8000000000000000L; // set variant
return new UUID(msb, lsb);
}
}

View file

@ -5,6 +5,8 @@ import androidx.room.Database;
import androidx.room.Room; import androidx.room.Room;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; import androidx.room.TypeConverters;
import im.conversations.android.database.dao.AccountDao;
import im.conversations.android.database.dao.PresenceDao;
import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.database.entity.BlockedItemEntity; import im.conversations.android.database.entity.BlockedItemEntity;
import im.conversations.android.database.entity.ChatEntity; import im.conversations.android.database.entity.ChatEntity;
@ -62,4 +64,8 @@ public abstract class ConversationsDatabase extends RoomDatabase {
return INSTANCE; return INSTANCE;
} }
} }
public abstract AccountDao accountDao();
public abstract PresenceDao presenceDao();
} }

View file

@ -0,0 +1,206 @@
package im.conversations.android.database;
import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
import androidx.annotation.NonNull;
import androidx.security.crypto.EncryptedFile;
import androidx.security.crypto.MasterKeys;
import com.google.common.collect.ImmutableMap;
import com.google.common.reflect.TypeToken;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import im.conversations.android.xmpp.sasl.ChannelBindingMechanism;
import im.conversations.android.xmpp.sasl.HashedToken;
import im.conversations.android.xmpp.sasl.SaslMechanism;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.lang.reflect.Type;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.Map;
// TODO cache credentials?!
public class CredentialStore {
private static final String FILENAME = "credential.store";
private static final Gson GSON = new GsonBuilder().create();
private static volatile CredentialStore INSTANCE;
private final Context context;
private CredentialStore(final Context context) {
this.context = context.getApplicationContext();
}
public static CredentialStore getInstance(final Context context) {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (CredentialStore.class) {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new CredentialStore(context);
return INSTANCE;
}
}
public synchronized Credential get(final Account account) {
return getOrEmpty(account);
}
public void setPassword(final Account account, final String password)
throws GeneralSecurityException, IOException {
setPassword(account, password, false);
}
public synchronized void setPassword(
final Account account, final String password, final boolean autogeneratedPassword)
throws GeneralSecurityException, IOException {
final Credential credential = getOrEmpty(account);
final Credential modifiedCredential =
new Credential(
password,
autogeneratedPassword,
credential.pinnedMechanism,
credential.pinnedChannelBinding,
credential.fastMechanism,
credential.fastToken,
credential.preAuthRegistrationToken,
credential.privateKeyAlias);
// TODO ignore if unchanged
this.set(account, modifiedCredential);
}
public void setFastToken(
final Account account, final HashedToken.Mechanism mechanism, final String token)
throws GeneralSecurityException, IOException {
final Credential credential = getOrEmpty(account);
final Credential modifiedCredential =
new Credential(
credential.password,
credential.autogeneratedPassword,
credential.pinnedMechanism,
credential.pinnedChannelBinding,
mechanism.name(),
token,
credential.preAuthRegistrationToken,
credential.privateKeyAlias);
// TODO ignore if unchanged
this.set(account, modifiedCredential);
}
public void resetFastToken(final Account account) throws GeneralSecurityException, IOException {
final Credential credential = getOrEmpty(account);
final Credential modifiedCredential =
new Credential(
credential.password,
credential.autogeneratedPassword,
credential.pinnedMechanism,
credential.pinnedChannelBinding,
null,
null,
credential.preAuthRegistrationToken,
credential.privateKeyAlias);
// TODO ignore if unchanged
this.set(account, modifiedCredential);
}
public void setPinnedMechanism(final Account account, final SaslMechanism mechanism)
throws GeneralSecurityException, IOException {
final String pinnedMechanism = mechanism.getMechanism();
final String pinnedChannelBinding;
if (mechanism instanceof ChannelBindingMechanism) {
pinnedChannelBinding =
((ChannelBindingMechanism) mechanism).getChannelBinding().toString();
} else {
pinnedChannelBinding = null;
}
final Credential credential = getOrEmpty(account);
final Credential modifiedCredential =
new Credential(
credential.password,
credential.autogeneratedPassword,
pinnedMechanism,
pinnedChannelBinding,
credential.fastMechanism,
credential.fastToken,
credential.preAuthRegistrationToken,
credential.privateKeyAlias);
// TODO ignore if unchanged
this.set(account, modifiedCredential);
}
public void resetPinnedMechanism(final Account account)
throws GeneralSecurityException, IOException {
final Credential credential = getOrEmpty(account);
final Credential modifiedCredential =
new Credential(
credential.password,
credential.autogeneratedPassword,
null,
null,
credential.fastMechanism,
credential.fastToken,
credential.preAuthRegistrationToken,
credential.privateKeyAlias);
// TODO ignore if unchanged
this.set(account, modifiedCredential);
}
private Credential getOrEmpty(final Account account) {
final Map<String, Credential> store = loadOrEmpty();
final Credential credential = store.get(account.address.toEscapedString());
return credential == null ? Credential.empty() : credential;
}
private void set(@NonNull final Account account, @NonNull final Credential credential)
throws GeneralSecurityException, IOException {
final HashMap<String, Credential> credentialStore = new HashMap<>(loadOrEmpty());
credentialStore.put(account.address.toEscapedString(), credential);
store(credentialStore);
}
private Map<String, Credential> loadOrEmpty() {
final Map<String, Credential> store;
try {
store = load();
} catch (final GeneralSecurityException | IOException e) {
return ImmutableMap.of();
}
return store == null ? ImmutableMap.of() : store;
}
private Map<String, Credential> load() throws GeneralSecurityException, IOException {
final EncryptedFile encryptedFile = getEncryptedFile();
final FileInputStream inputStream = encryptedFile.openFileInput();
final Type type = new TypeToken<Map<String, Credential>>() {}.getType();
return GSON.fromJson(new InputStreamReader(inputStream), type);
}
private void store(final Map<String, Credential> store)
throws GeneralSecurityException, IOException {
final EncryptedFile encryptedFile = getEncryptedFile();
final FileOutputStream outputStream = encryptedFile.openFileOutput();
GSON.toJson(store, new OutputStreamWriter(outputStream));
}
private EncryptedFile getEncryptedFile() throws GeneralSecurityException, IOException {
final KeyGenParameterSpec keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC;
final String mainKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec);
return new EncryptedFile.Builder(
new File(context.getFilesDir(), FILENAME),
context,
mainKeyAlias,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB)
.build();
}
}

View file

@ -0,0 +1,64 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import com.google.common.util.concurrent.ListenableFuture;
import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Connection;
import java.util.List;
@Dao
public interface AccountDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
void insert(final AccountEntity account);
@Query("SELECT id,address,randomSeed FROM account WHERE enabled = 1")
ListenableFuture<List<Account>> getEnabledAccounts();
@Query("SELECT hostname,port,directTls FROM account WHERE id=:id AND hostname != null")
Connection getConnectionSettings(long id);
@Query("SELECT resource FROM account WHERE id=:id")
String getResource(long id);
@Query("SELECT rosterVersion FROM account WHERE id=:id")
String getRosterVersion(long id);
@Query("SELECT quickStartAvailable FROM account where id=:id")
boolean quickStartAvailable(long id);
@Query("SELECT pendingRegistration FROM account where id=:id")
boolean pendingRegistration(long id);
@Query("SELECT loggedInSuccessfully == 0 FROM account where id=:id")
boolean isInitialLogin(long id);
@Query(
"UPDATE account set quickStartAvailable=:available WHERE id=:id AND"
+ " quickStartAvailable != :available")
void setQuickStartAvailable(long id, boolean available);
@Query(
"UPDATE account set pendingRegistration=:pendingRegistration WHERE id=:id AND"
+ " pendingRegistration != :pendingRegistration")
void setPendingRegistration(long id, boolean pendingRegistration);
@Query(
"UPDATE account set loggedInSuccessfully=:loggedInSuccessfully WHERE id=:id AND"
+ " loggedInSuccessfully != :loggedInSuccessfully")
int setLoggedInSuccessfully(long id, boolean loggedInSuccessfully);
@Query(
"UPDATE account set showErrorNotification=:showErrorNotification WHERE id=:id AND"
+ " showErrorNotification != :showErrorNotification")
int setShowErrorNotification(long id, boolean showErrorNotification);
@Query("UPDATE account set resource=:resource WHERE id=:id")
void setResource(long id, String resource);
// TODO on disable set resource to null
}

View file

@ -0,0 +1,11 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Query;
@Dao
public interface PresenceDao {
@Query("DELETE FROM presence WHERE accountId=:account")
void deletePresences(long account);
}

View file

@ -26,6 +26,15 @@ public class AccountEntity {
public boolean enabled; public boolean enabled;
public boolean quickStartAvailable = false;
public boolean pendingRegistration = false;
// TODO this is only used during setup; depending on how the setup procedure will look in the
// future we might get rid of this property
public boolean loggedInSuccessfully = false;
public boolean showErrorNotification = true;
public String rosterVersion; public String rosterVersion;
@Embedded public Connection connection; @Embedded public Connection connection;

View file

@ -50,4 +50,7 @@ public class PresenceEntity {
@Nullable public MucOptions.Role mucUserRole; @Nullable public MucOptions.Role mucUserRole;
@Nullable public Jid mucUserJid; @Nullable public Jid mucUserJid;
// set to true if presence has status code 110 (this means we are online)
public boolean mucUserSelf;
} }

View file

@ -0,0 +1,57 @@
package im.conversations.android.database.model;
import androidx.annotation.NonNull;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import com.google.common.hash.Hashing;
import com.google.common.io.ByteSource;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.Uuids;
import java.io.IOException;
import java.util.UUID;
public class Account {
public final long id;
@NonNull public final Jid address;
@NonNull public final byte[] randomSeed;
public Account(final long id, @NonNull final Jid address, @NonNull byte[] randomSeed) {
Preconditions.checkNotNull(address, "Account can not be instantiated without an address");
Preconditions.checkArgument(address.isBareJid(), "Account address must be bare");
Preconditions.checkArgument(
randomSeed.length == 32, "RandomSeed must have exactly 32 bytes");
this.id = id;
this.address = address;
this.randomSeed = randomSeed;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Account account = (Account) o;
return id == account.id
&& Objects.equal(address, account.address)
&& Objects.equal(randomSeed, account.randomSeed);
}
@Override
public int hashCode() {
return Objects.hashCode(id, address, randomSeed);
}
public boolean isOnion() {
final String domain = address.getDomain().toEscapedString();
return domain.endsWith(".onion");
}
public UUID getPublicDeviceId() {
try {
return Uuids.getUuid(
ByteSource.wrap(randomSeed).slice(0, 16).hash(Hashing.sha256()).asBytes());
} catch (final IOException e) {
return UUID.randomUUID();
}
}
}

View file

@ -0,0 +1,50 @@
package im.conversations.android.database.model;
public class Credential {
public final String password;
public final boolean autogeneratedPassword;
public final String pinnedMechanism;
public final String pinnedChannelBinding;
public final String fastMechanism;
public final String fastToken;
public final String preAuthRegistrationToken;
public final String privateKeyAlias;
private Credential() {
this.password = null;
this.autogeneratedPassword = false;
this.pinnedMechanism = null;
this.pinnedChannelBinding = null;
this.fastMechanism = null;
this.fastToken = null;
this.preAuthRegistrationToken = null;
this.privateKeyAlias = null;
}
public Credential(
String password,
boolean autogeneratedPassword,
String pinnedMechanism,
String pinnedChannelBinding,
String fastMechanism,
String fastToken,
String preAuthRegistrationToken,
String privateKeyAlias) {
this.password = password;
this.autogeneratedPassword = autogeneratedPassword;
this.pinnedMechanism = pinnedMechanism;
this.pinnedChannelBinding = pinnedChannelBinding;
this.fastMechanism = fastMechanism;
this.fastToken = fastToken;
this.preAuthRegistrationToken = preAuthRegistrationToken;
this.privateKeyAlias = privateKeyAlias;
}
public static Credential empty() {
return new Credential();
}
}

View file

@ -0,0 +1,356 @@
package im.conversations.android.xmpp;
import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
import android.content.Context;
import android.os.SystemClock;
import android.util.Log;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.primitives.Ints;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.PhoneHelper;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.model.Account;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ConnectionPool {
private static volatile ConnectionPool INSTANCE;
private final Context context;
private final Executor reconfigurationExecutor = Executors.newSingleThreadExecutor();
private final ScheduledExecutorService reconnectExecutor =
Executors.newSingleThreadScheduledExecutor();
private final List<XmppConnection> connections = new ArrayList<>();
private final HashSet<Jid> lowPingTimeoutMode = new HashSet<>();
private ConnectionPool(final Context context) {
this.context = context.getApplicationContext();
}
public ListenableFuture<Void> reconfigure() {
final ListenableFuture<List<Account>> accountFuture =
ConversationsDatabase.getInstance(context).accountDao().getEnabledAccounts();
return Futures.transform(
accountFuture,
accounts -> this.reconfigure(ImmutableSet.copyOf(accounts)),
reconfigurationExecutor);
}
public synchronized XmppConnection get(final Jid address) {
return Iterables.find(this.connections, c -> address.equals(c.getAccount().address));
}
public synchronized XmppConnection get(final long id) {
return Iterables.find(this.connections, c -> id == c.getAccount().id);
}
public synchronized boolean isEnabled(final long id) {
return Iterables.any(this.connections, c -> id == c.getAccount().id);
}
public synchronized List<XmppConnection> getConnections() {
return ImmutableList.copyOf(this.connections);
}
private synchronized Void reconfigure(final Set<Account> accounts) {
final Set<Account> current = getAccounts();
final Set<Account> removed = Sets.difference(current, accounts);
final Set<Account> added = Sets.difference(accounts, current);
for (final Account account : added) {
final XmppConnection connection = this.instantiate(context, account);
connection.setOnStatusChangedListener(this::onStatusChanged);
}
for (final Account account : removed) {
final Optional<XmppConnection> connectionOptional =
Iterables.tryFind(connections, c -> c.getAccount().equals(account));
if (connectionOptional.isPresent()) {
final XmppConnection connection = connectionOptional.get();
disconnect(connection, false);
}
}
return null;
}
private void onStatusChanged(final XmppConnection connection) {
final Account account = connection.getAccount();
if (connection.getStatus() == ConnectionState.ONLINE || connection.getStatus().isError()) {
// TODO notify QuickConversationsService of account state change
// mQuickConversationsService.signalAccountStateChange();
}
if (connection.getStatus() == ConnectionState.ONLINE) {
synchronized (lowPingTimeoutMode) {
if (lowPingTimeoutMode.remove(account.address)) {
Log.d(Config.LOGTAG, account.address + ": leaving low ping timeout mode");
}
}
ConversationsDatabase.getInstance(context)
.accountDao()
.setShowErrorNotification(account.id, true);
if (connection.getFeatures().csi()) {
// TODO send correct CSI state (connection.sendActive or connection.sendInactive)
}
scheduleWakeUpCall(Config.PING_MAX_INTERVAL);
} else if (connection.getStatus() == ConnectionState.OFFLINE) {
// TODO previously we would call resetSendingToWaiting. The new architecture likely
// wont need this but we should double check
// resetSendingToWaiting(account);
if (isInLowPingTimeoutMode(account)) {
Log.d(
Config.LOGTAG,
account.address
+ ": went into offline state during low ping mode."
+ " reconnecting now");
reconnectAccount(connection);
} else {
final int timeToReconnect = SECURE_RANDOM.nextInt(10) + 2;
scheduleWakeUpCall(timeToReconnect);
}
} else if (connection.getStatus() == ConnectionState.REGISTRATION_SUCCESSFUL) {
// databaseBackend.updateAccount(account);
reconnectAccount(connection);
} else if (connection.getStatus() != ConnectionState.CONNECTING) {
// resetSendingToWaiting(account);
if (connection.getStatus().isAttemptReconnect()) {
final int next = connection.getTimeToNextAttempt();
final boolean lowPingTimeoutMode = isInLowPingTimeoutMode(account);
if (next <= 0) {
Log.d(
Config.LOGTAG,
account.address
+ ": error connecting account. reconnecting now."
+ " lowPingTimeout="
+ lowPingTimeoutMode);
reconnectAccount(connection);
} else {
final int attempt = connection.getAttempt() + 1;
Log.d(
Config.LOGTAG,
account.address
+ ": error connecting account. try again in "
+ next
+ "s for the "
+ attempt
+ " time. lowPingTimeout="
+ lowPingTimeoutMode);
scheduleWakeUpCall(next);
}
}
}
// TODO toggle error notification
// getNotificationService().updateErrorNotification();
}
public void scheduleWakeUpCall(final int seconds) {
reconnectExecutor.schedule(
() -> {
manageConnectionStates();
},
Math.max(0, seconds) + 1,
TimeUnit.SECONDS);
}
/** This is called externally if we want to force pings for example on connection switches */
public void ping() {
manageConnectionStates(null, true);
}
/**
* This is called externally from the push receiver
*
* @param pushedAccountHash
*/
public void receivePush(final String pushedAccountHash) {
manageConnectionStates(pushedAccountHash, false);
}
private void manageConnectionStates() {
manageConnectionStates(null, false);
}
private void manageConnectionStates(
final String pushedAccountHash, final boolean immediatePing) {
// WakeLockHelper.acquire(wakeLock);
int pingNow = 0;
final HashSet<XmppConnection> pingCandidates = new HashSet<>();
final String androidId = PhoneHelper.getAndroidId(context);
for (final XmppConnection xmppConnection : this.connections) {
final Account account = xmppConnection.getAccount();
final boolean pushWasMeantForThisAccount =
CryptoHelper.getFingerprint(account.address, androidId)
.equals(pushedAccountHash);
if (processAccountState(xmppConnection, pushWasMeantForThisAccount, pingCandidates)) {
pingNow++;
}
}
if (pingNow > 0 || immediatePing) {
for (final XmppConnection xmppConnection : pingCandidates) {
final Account account = xmppConnection.getAccount();
final boolean lowTimeout = isInLowPingTimeoutMode(account);
xmppConnection.sendPing();
Log.d(
Config.LOGTAG,
account.address + " send ping (lowTimeout=" + lowTimeout + ")");
scheduleWakeUpCall(lowTimeout ? Config.LOW_PING_TIMEOUT : Config.PING_TIMEOUT);
}
}
// WakeLockHelper.release(wakeLock);
}
private boolean processAccountState(
final XmppConnection connection,
final boolean isAccountPushed,
final HashSet<XmppConnection> pingCandidates) {
boolean pingNow = false;
if (connection.getStatus().isAttemptReconnect()) {
final Account account = connection.getAccount();
if (connection.getStatus() == ConnectionState.ONLINE) {
synchronized (lowPingTimeoutMode) {
final long lastReceived = connection.getLastPacketReceived();
final long lastSent = connection.getLastPingSent();
final long msToNextPing =
(Math.max(lastReceived, lastSent) + Config.PING_MAX_INTERVAL)
- SystemClock.elapsedRealtime();
final int pingTimeout =
lowPingTimeoutMode.contains(account.address)
? Config.LOW_PING_TIMEOUT * 1000
: Config.PING_TIMEOUT * 1000;
final long pingTimeoutIn =
(lastSent + pingTimeout) - SystemClock.elapsedRealtime();
if (lastSent > lastReceived) {
if (pingTimeoutIn < 0) {
Log.d(Config.LOGTAG, account.address + ": ping timeout");
this.reconnectAccount(connection);
} else {
final int secs = (int) (pingTimeoutIn / 1000);
this.scheduleWakeUpCall(secs);
}
} else {
pingCandidates.add(connection);
if (isAccountPushed) {
pingNow = true;
if (lowPingTimeoutMode.add(account.address)) {
Log.d(
Config.LOGTAG,
account.address + ": entering low ping timeout mode");
}
} else if (msToNextPing <= 0) {
pingNow = true;
} else {
this.scheduleWakeUpCall(Ints.saturatedCast(msToNextPing / 1000));
if (lowPingTimeoutMode.remove(account.address)) {
Log.d(
Config.LOGTAG,
account.address + ": leaving low ping timeout mode");
}
}
}
}
} else if (connection.getStatus() == ConnectionState.OFFLINE) {
reconnectAccount(connection);
} else if (connection.getStatus() == ConnectionState.CONNECTING) {
long secondsSinceLastConnect =
(SystemClock.elapsedRealtime() - connection.getLastConnect()) / 1000;
long secondsSinceLastDisco =
(SystemClock.elapsedRealtime() - connection.getLastDiscoStarted()) / 1000;
long discoTimeout = Config.CONNECT_DISCO_TIMEOUT - secondsSinceLastDisco;
long timeout = Config.CONNECT_TIMEOUT - secondsSinceLastConnect;
if (timeout < 0) {
Log.d(
Config.LOGTAG,
account.address
+ ": time out during connect reconnecting"
+ " (secondsSinceLast="
+ secondsSinceLastConnect
+ ")");
connection.resetAttemptCount(false);
reconnectAccount(connection);
} else if (discoTimeout < 0) {
connection.sendDiscoTimeout();
scheduleWakeUpCall(Ints.saturatedCast(discoTimeout));
} else {
scheduleWakeUpCall(Ints.saturatedCast(Math.min(timeout, discoTimeout)));
}
} else {
if (connection.getTimeToNextAttempt() <= 0) {
reconnectAccount(connection);
}
}
}
return pingNow;
}
private void reconnectAccount(final XmppConnection connection) {
final Account account = connection.getAccount();
if (isEnabled(account.id)) {
final Thread thread = new Thread(connection);
connection.prepareNewConnection();
connection.interrupt();
thread.start();
scheduleWakeUpCall(Config.CONNECT_DISCO_TIMEOUT);
} else {
disconnect(connection, true);
connection.resetEverything();
}
}
private void disconnect(final XmppConnection connection, boolean force) {
if (force) {
connection.disconnect(true);
} else {
// TODO bring back code that gracefully leaves MUCs
// TODO send offline presence
connection.disconnect(false);
}
}
private boolean isInLowPingTimeoutMode(Account account) {
synchronized (lowPingTimeoutMode) {
return lowPingTimeoutMode.contains(account.address);
}
}
private XmppConnection instantiate(final Context context, final Account account) {
final XmppConnection xmppConnection = new XmppConnection(context, account);
this.connections.add(xmppConnection);
return xmppConnection;
}
private Set<Account> getAccounts() {
return ImmutableSet.copyOf(Lists.transform(this.connections, XmppConnection::getAccount));
}
public static ConnectionPool getInstance(final Context context) {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (ConnectionPool.class) {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new ConnectionPool(context);
return INSTANCE;
}
}
}

View file

@ -0,0 +1,124 @@
package im.conversations.android.xmpp;
import androidx.annotation.StringRes;
import eu.siacs.conversations.R;
public enum ConnectionState {
OFFLINE(false),
CONNECTING(false),
ONLINE(false),
UNAUTHORIZED,
TEMPORARY_AUTH_FAILURE,
SERVER_NOT_FOUND,
REGISTRATION_SUCCESSFUL(false),
REGISTRATION_FAILED(true, false),
REGISTRATION_WEB(true, false),
REGISTRATION_CONFLICT(true, false),
REGISTRATION_NOT_SUPPORTED(true, false),
REGISTRATION_PLEASE_WAIT(true, false),
REGISTRATION_INVALID_TOKEN(true, false),
REGISTRATION_PASSWORD_TOO_WEAK(true, false),
TLS_ERROR,
TLS_ERROR_DOMAIN,
INCOMPATIBLE_SERVER,
INCOMPATIBLE_CLIENT,
TOR_NOT_AVAILABLE,
DOWNGRADE_ATTACK,
SESSION_FAILURE,
BIND_FAILURE,
HOST_UNKNOWN,
STREAM_ERROR,
STREAM_OPENING_ERROR,
POLICY_VIOLATION,
PAYMENT_REQUIRED,
MISSING_INTERNET_PERMISSION(false);
private final boolean isError;
private final boolean attemptReconnect;
ConnectionState(final boolean isError) {
this(isError, true);
}
ConnectionState(final boolean isError, final boolean reconnect) {
this.isError = isError;
this.attemptReconnect = reconnect;
}
ConnectionState() {
this(true, true);
}
public boolean isError() {
return this.isError;
}
public boolean isAttemptReconnect() {
return this.attemptReconnect;
}
// TODO refactor into DataBinder (we can print the enum directly in the UI)
@StringRes
public int getReadableId() {
switch (this) {
case ONLINE:
return R.string.account_status_online;
case CONNECTING:
return R.string.account_status_connecting;
case OFFLINE:
return R.string.account_status_offline;
case UNAUTHORIZED:
return R.string.account_status_unauthorized;
case SERVER_NOT_FOUND:
return R.string.account_status_not_found;
case REGISTRATION_FAILED:
return R.string.account_status_regis_fail;
case REGISTRATION_WEB:
return R.string.account_status_regis_web;
case REGISTRATION_CONFLICT:
return R.string.account_status_regis_conflict;
case REGISTRATION_SUCCESSFUL:
return R.string.account_status_regis_success;
case REGISTRATION_NOT_SUPPORTED:
return R.string.account_status_regis_not_sup;
case REGISTRATION_INVALID_TOKEN:
return R.string.account_status_regis_invalid_token;
case TLS_ERROR:
return R.string.account_status_tls_error;
case TLS_ERROR_DOMAIN:
return R.string.account_status_tls_error_domain;
case INCOMPATIBLE_SERVER:
return R.string.account_status_incompatible_server;
case INCOMPATIBLE_CLIENT:
return R.string.account_status_incompatible_client;
case TOR_NOT_AVAILABLE:
return R.string.account_status_tor_unavailable;
case BIND_FAILURE:
return R.string.account_status_bind_failure;
case SESSION_FAILURE:
return R.string.session_failure;
case DOWNGRADE_ATTACK:
return R.string.sasl_downgrade;
case HOST_UNKNOWN:
return R.string.account_status_host_unknown;
case POLICY_VIOLATION:
return R.string.account_status_policy_violation;
case REGISTRATION_PLEASE_WAIT:
return R.string.registration_please_wait;
case REGISTRATION_PASSWORD_TOO_WEAK:
return R.string.registration_password_too_weak;
case STREAM_ERROR:
return R.string.account_status_stream_error;
case STREAM_OPENING_ERROR:
return R.string.account_status_stream_opening_error;
case PAYMENT_REQUIRED:
return R.string.payment_required;
case MISSING_INTERNET_PERMISSION:
return R.string.missing_internet_permission;
case TEMPORARY_AUTH_FAILURE:
return R.string.account_status_temporary_auth_failure;
default:
return R.string.account_status_unknown;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.model.Account;
import im.conversations.android.xmpp.XmppConnection;
abstract class BaseProcessor {
protected final Context context;
protected final XmppConnection connection;
BaseProcessor(final Context context, final XmppConnection connection) {
this.context = context;
this.connection = connection;
}
protected Account getAccount() {
return connection.getAccount();
}
protected ConversationsDatabase getDatabase() {
return ConversationsDatabase.getInstance(context);
}
}

View file

@ -0,0 +1,54 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import android.util.Log;
import com.google.common.base.Strings;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import im.conversations.android.xmpp.XmppConnection;
import java.util.function.Consumer;
public class BindProcessor extends BaseProcessor implements Consumer<Jid> {
public BindProcessor(final Context context, final XmppConnection connection) {
super(context, connection);
}
@Override
public void accept(final Jid jid) {
final var account = getAccount();
final var database = getDatabase();
final boolean firstLogin =
database.accountDao().setLoggedInSuccessfully(account.id, true) > 0;
if (firstLogin) {
// TODO publish display name if this is the first attempt
// iirc this is used when the display name is set from a certificate or something
}
database.presenceDao().deletePresences(account.id);
fetchRoster();
// TODO fetch bookmarks
// TODO send initial presence
}
private void fetchRoster() {
final var account = getAccount();
final var database = getDatabase();
final String rosterVersion = database.accountDao().getRosterVersion(account.id);
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
if (Strings.isNullOrEmpty(rosterVersion)) {
Log.d(Config.LOGTAG, account.address + ": fetching roster");
} else {
Log.d(Config.LOGTAG, account.address + ": fetching roster version " + rosterVersion);
}
iqPacket.query(Namespace.ROSTER).setAttribute("ver", rosterVersion);
connection.sendIqPacket(iqPacket, result -> {});
}
}

View file

@ -0,0 +1,14 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import im.conversations.android.xmpp.XmppConnection;
import java.util.function.Consumer;
public class IqProcessor implements Consumer<IqPacket> {
public IqProcessor(final Context context, final XmppConnection connection) {}
@Override
public void accept(final IqPacket packet) {}
}

View file

@ -0,0 +1,14 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import im.conversations.android.xmpp.XmppConnection;
import java.util.function.Consumer;
public class JingleProcessor implements Consumer<JinglePacket> {
public JingleProcessor(final Context context, final XmppConnection connection) {}
@Override
public void accept(JinglePacket jinglePacket) {}
}

View file

@ -0,0 +1,16 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import eu.siacs.conversations.xmpp.Jid;
import im.conversations.android.xmpp.XmppConnection;
import java.util.function.BiFunction;
public class MessageAcknowledgeProcessor implements BiFunction<Jid, String, Boolean> {
public MessageAcknowledgeProcessor(final Context context, final XmppConnection connection) {}
@Override
public Boolean apply(final Jid to, final String id) {
return null;
}
}

View file

@ -0,0 +1,14 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
import im.conversations.android.xmpp.XmppConnection;
import java.util.function.Consumer;
public class MessageProcessor implements Consumer<MessagePacket> {
public MessageProcessor(final Context context, final XmppConnection connection) {}
@Override
public void accept(final MessagePacket messagePacket) {}
}

View file

@ -0,0 +1,14 @@
package im.conversations.android.xmpp.processor;
import android.content.Context;
import eu.siacs.conversations.xmpp.stanzas.PresencePacket;
import im.conversations.android.xmpp.XmppConnection;
import java.util.function.Consumer;
public class PresenceProcessor implements Consumer<PresencePacket> {
public PresenceProcessor(final Context context, final XmppConnection connection) {}
@Override
public void accept(PresencePacket presencePacket) {}
}

View file

@ -0,0 +1,29 @@
package im.conversations.android.xmpp.sasl;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import javax.net.ssl.SSLSocket;
public class Anonymous extends SaslMechanism {
public static final String MECHANISM = "ANONYMOUS";
public Anonymous(final Account account) {
super(account, Credential.empty());
}
@Override
public int getPriority() {
return 0;
}
@Override
public String getMechanism() {
return MECHANISM;
}
@Override
public String getClientFirstMessage(final SSLSocket sslSocket) {
return "";
}
}

View file

@ -0,0 +1,117 @@
package im.conversations.android.xmpp.sasl;
import android.util.Log;
import com.google.common.base.CaseFormat;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicates;
import com.google.common.base.Strings;
import com.google.common.collect.BiMap;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableBiMap;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.SSLSockets;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
public enum ChannelBinding {
NONE,
TLS_EXPORTER,
TLS_SERVER_END_POINT,
TLS_UNIQUE;
public static final BiMap<ChannelBinding, String> SHORT_NAMES;
static {
final ImmutableBiMap.Builder<ChannelBinding, String> builder = ImmutableBiMap.builder();
for (final ChannelBinding cb : values()) {
builder.put(cb, shortName(cb));
}
SHORT_NAMES = builder.build();
}
public static Collection<ChannelBinding> of(final Element channelBinding) {
Preconditions.checkArgument(
channelBinding == null
|| ("sasl-channel-binding".equals(channelBinding.getName())
&& Namespace.CHANNEL_BINDING.equals(channelBinding.getNamespace())),
"pass null or a valid channel binding stream feature");
return Collections2.filter(
Collections2.transform(
Collections2.filter(
channelBinding == null
? Collections.emptyList()
: channelBinding.getChildren(),
c -> c != null && "channel-binding".equals(c.getName())),
c -> c == null ? null : ChannelBinding.of(c.getAttribute("type"))),
Predicates.notNull());
}
private static ChannelBinding of(final String type) {
if (type == null) {
return null;
}
try {
return valueOf(
CaseFormat.LOWER_HYPHEN.converterTo(CaseFormat.UPPER_UNDERSCORE).convert(type));
} catch (final IllegalArgumentException e) {
Log.d(Config.LOGTAG, type + " is not a known channel binding");
return null;
}
}
public static ChannelBinding get(final String name) {
if (Strings.isNullOrEmpty(name)) {
return NONE;
}
try {
return valueOf(name);
} catch (final IllegalArgumentException e) {
return NONE;
}
}
public static ChannelBinding best(
final Collection<ChannelBinding> bindings, final SSLSockets.Version sslVersion) {
if (sslVersion == SSLSockets.Version.NONE) {
return NONE;
}
if (bindings.contains(TLS_EXPORTER) && sslVersion == SSLSockets.Version.TLS_1_3) {
return TLS_EXPORTER;
} else if (bindings.contains(TLS_UNIQUE)
&& Arrays.asList(
SSLSockets.Version.TLS_1_0,
SSLSockets.Version.TLS_1_1,
SSLSockets.Version.TLS_1_2)
.contains(sslVersion)) {
return TLS_UNIQUE;
} else if (bindings.contains(TLS_SERVER_END_POINT)) {
return TLS_SERVER_END_POINT;
} else {
return NONE;
}
}
public static boolean isAvailable(
final ChannelBinding channelBinding, final SSLSockets.Version sslVersion) {
return ChannelBinding.best(Collections.singleton(channelBinding), sslVersion)
== channelBinding;
}
private static String shortName(final ChannelBinding channelBinding) {
switch (channelBinding) {
case TLS_UNIQUE:
return "UNIQ";
case TLS_EXPORTER:
return "EXPR";
case TLS_SERVER_END_POINT:
return "ENDP";
case NONE:
return "NONE";
default:
throw new AssertionError("Missing short name for " + channelBinding);
}
}
}

View file

@ -0,0 +1,100 @@
package im.conversations.android.xmpp.sasl;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import org.bouncycastle.jcajce.provider.digest.SHA256;
import org.conscrypt.Conscrypt;
public interface ChannelBindingMechanism {
String EXPORTER_LABEL = "EXPORTER-Channel-Binding";
ChannelBinding getChannelBinding();
static byte[] getChannelBindingData(
final SSLSocket sslSocket, final ChannelBinding channelBinding)
throws SaslMechanism.AuthenticationException {
if (sslSocket == null) {
throw new SaslMechanism.AuthenticationException(
"Channel binding attempt on non secure socket");
}
if (channelBinding == ChannelBinding.TLS_EXPORTER) {
final byte[] keyingMaterial;
try {
keyingMaterial =
Conscrypt.exportKeyingMaterial(sslSocket, EXPORTER_LABEL, new byte[0], 32);
} catch (final SSLException e) {
throw new SaslMechanism.AuthenticationException("Could not export keying material");
}
if (keyingMaterial == null) {
throw new SaslMechanism.AuthenticationException(
"Could not export keying material. Socket not ready");
}
return keyingMaterial;
} else if (channelBinding == ChannelBinding.TLS_UNIQUE) {
final byte[] unique = Conscrypt.getTlsUnique(sslSocket);
if (unique == null) {
throw new SaslMechanism.AuthenticationException(
"Could not retrieve tls unique. Socket not ready");
}
return unique;
} else if (channelBinding == ChannelBinding.TLS_SERVER_END_POINT) {
return getServerEndPointChannelBinding(sslSocket.getSession());
} else {
throw new SaslMechanism.AuthenticationException(
String.format("%s is not a valid channel binding", channelBinding));
}
}
static byte[] getServerEndPointChannelBinding(final SSLSession session)
throws SaslMechanism.AuthenticationException {
final Certificate[] certificates;
try {
certificates = session.getPeerCertificates();
} catch (final SSLPeerUnverifiedException e) {
throw new SaslMechanism.AuthenticationException("Could not verify peer certificates");
}
if (certificates == null || certificates.length == 0) {
throw new SaslMechanism.AuthenticationException("Could not retrieve peer certificate");
}
final X509Certificate certificate;
if (certificates[0] instanceof X509Certificate) {
certificate = (X509Certificate) certificates[0];
} else {
throw new SaslMechanism.AuthenticationException("Certificate was not X509");
}
final String algorithm = certificate.getSigAlgName();
final int withIndex = algorithm.indexOf("with");
if (withIndex <= 0) {
throw new SaslMechanism.AuthenticationException("Unable to parse SigAlgName");
}
final String hashAlgorithm = algorithm.substring(0, withIndex);
final MessageDigest messageDigest;
// https://www.rfc-editor.org/rfc/rfc5929#section-4.1
if ("MD5".equalsIgnoreCase(hashAlgorithm) || "SHA1".equalsIgnoreCase(hashAlgorithm)) {
messageDigest = new SHA256.Digest();
} else {
try {
messageDigest = MessageDigest.getInstance(hashAlgorithm);
} catch (final NoSuchAlgorithmException e) {
throw new SaslMechanism.AuthenticationException(
"Could not instantiate message digest for " + hashAlgorithm);
}
}
final byte[] encodedCertificate;
try {
encodedCertificate = certificate.getEncoded();
} catch (final CertificateEncodingException e) {
throw new SaslMechanism.AuthenticationException("Could not encode certificate");
}
messageDigest.update(encodedCertificate);
return messageDigest.digest();
}
}

View file

@ -0,0 +1,112 @@
package im.conversations.android.xmpp.sasl;
import android.util.Base64;
import eu.siacs.conversations.utils.CryptoHelper;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.net.ssl.SSLSocket;
public class DigestMd5 extends SaslMechanism {
public static final String MECHANISM = "DIGEST-MD5";
private State state = State.INITIAL;
public DigestMd5(final Account account, final Credential credential) {
super(account, credential);
}
@Override
public int getPriority() {
return 10;
}
@Override
public String getMechanism() {
return MECHANISM;
}
@Override
public String getResponse(final String challenge, final SSLSocket sslSocket)
throws AuthenticationException {
switch (state) {
case INITIAL:
state = State.RESPONSE_SENT;
final String encodedResponse;
try {
final Tokenizer tokenizer =
new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
String nonce = "";
for (final String token : tokenizer) {
final String[] parts = token.split("=", 2);
if (parts[0].equals("nonce")) {
nonce = parts[1].replace("\"", "");
} else if (parts[0].equals("rspauth")) {
return "";
}
}
final String digestUri = "xmpp/" + account.address.getDomain();
final String nonceCount = "00000001";
final String x =
account.address.getEscapedLocal()
+ ":"
+ account.address.getDomain()
+ ":"
+ credential.password;
final MessageDigest md = MessageDigest.getInstance("MD5");
final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
final String cNonce = CryptoHelper.random(100);
final byte[] a1 =
CryptoHelper.concatenateByteArrays(
y,
(":" + nonce + ":" + cNonce)
.getBytes(Charset.defaultCharset()));
final String a2 = "AUTHENTICATE:" + digestUri;
final String ha1 = CryptoHelper.bytesToHex(md.digest(a1));
final String ha2 =
CryptoHelper.bytesToHex(
md.digest(a2.getBytes(Charset.defaultCharset())));
final String kd =
ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2;
final String response =
CryptoHelper.bytesToHex(
md.digest(kd.getBytes(Charset.defaultCharset())));
final String saslString =
"username=\""
+ account.address.getEscapedLocal()
+ "\",realm=\""
+ account.address.getDomain()
+ "\",nonce=\""
+ nonce
+ "\",cnonce=\""
+ cNonce
+ "\",nc="
+ nonceCount
+ ",qop=auth,digest-uri=\""
+ digestUri
+ "\",response="
+ response
+ ",charset=utf-8";
encodedResponse =
Base64.encodeToString(
saslString.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
} catch (final NoSuchAlgorithmException e) {
throw new AuthenticationException(e);
}
return encodedResponse;
case RESPONSE_SENT:
state = State.VALID_SERVER_RESPONSE;
break;
case VALID_SERVER_RESPONSE:
if (challenge == null) {
return null; // everything is fine
}
default:
throw new InvalidStateException(state);
}
return null;
}
}

View file

@ -0,0 +1,30 @@
package im.conversations.android.xmpp.sasl;
import android.util.Base64;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import javax.net.ssl.SSLSocket;
public class External extends SaslMechanism {
public static final String MECHANISM = "EXTERNAL";
public External(final Account account) {
super(account, Credential.empty());
}
@Override
public int getPriority() {
return 25;
}
@Override
public String getMechanism() {
return MECHANISM;
}
@Override
public String getClientFirstMessage(final SSLSocket sslSocket) {
return Base64.encodeToString(account.address.toEscapedString().getBytes(), Base64.NO_WRAP);
}
}

View file

@ -0,0 +1,189 @@
package im.conversations.android.xmpp.sasl;
import android.util.Base64;
import android.util.Log;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Multimap;
import com.google.common.hash.HashFunction;
import com.google.common.primitives.Bytes;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.SSLSockets;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import javax.net.ssl.SSLSocket;
import org.jetbrains.annotations.NotNull;
public abstract class HashedToken extends SaslMechanism implements ChannelBindingMechanism {
private static final String PREFIX = "HT";
private static final List<String> HASH_FUNCTIONS = Arrays.asList("SHA-512", "SHA-256");
private static final byte[] INITIATOR = "Initiator".getBytes(StandardCharsets.UTF_8);
private static final byte[] RESPONDER = "Responder".getBytes(StandardCharsets.UTF_8);
protected final ChannelBinding channelBinding;
protected HashedToken(
final Account account,
final Credential credential,
final ChannelBinding channelBinding) {
super(account, credential);
this.channelBinding = channelBinding;
}
@Override
public int getPriority() {
throw new UnsupportedOperationException();
}
@Override
public String getClientFirstMessage(final SSLSocket sslSocket) {
final String token = Strings.nullToEmpty(this.credential.fastToken);
final HashFunction hashing = getHashFunction(token.getBytes(StandardCharsets.UTF_8));
final byte[] cbData = getChannelBindingData(sslSocket);
final byte[] initiatorHashedToken =
hashing.hashBytes(Bytes.concat(INITIATOR, cbData)).asBytes();
final byte[] firstMessage =
Bytes.concat(
account.address.getEscapedLocal().getBytes(StandardCharsets.UTF_8),
new byte[] {0x00},
initiatorHashedToken);
return Base64.encodeToString(firstMessage, Base64.NO_WRAP);
}
private byte[] getChannelBindingData(final SSLSocket sslSocket) {
if (this.channelBinding == ChannelBinding.NONE) {
return new byte[0];
}
try {
return ChannelBindingMechanism.getChannelBindingData(sslSocket, this.channelBinding);
} catch (final AuthenticationException e) {
Log.e(
Config.LOGTAG,
account.address
+ ": unable to retrieve channel binding data for "
+ getMechanism(),
e);
return new byte[0];
}
}
@Override
public String getResponse(final String challenge, final SSLSocket socket)
throws AuthenticationException {
final byte[] responderMessage;
try {
responderMessage = Base64.decode(challenge, Base64.NO_WRAP);
} catch (final Exception e) {
throw new AuthenticationException("Unable to decode responder message", e);
}
final String token = Strings.nullToEmpty(this.credential.fastToken);
final HashFunction hashing = getHashFunction(token.getBytes(StandardCharsets.UTF_8));
final byte[] cbData = getChannelBindingData(socket);
final byte[] expectedResponderMessage =
hashing.hashBytes(Bytes.concat(RESPONDER, cbData)).asBytes();
if (Arrays.equals(responderMessage, expectedResponderMessage)) {
return null;
}
throw new AuthenticationException("Responder message did not match");
}
protected abstract HashFunction getHashFunction(final byte[] key);
public abstract Mechanism getTokenMechanism();
@Override
public String getMechanism() {
return getTokenMechanism().name();
}
public static final class Mechanism {
public final String hashFunction;
public final ChannelBinding channelBinding;
public Mechanism(String hashFunction, ChannelBinding channelBinding) {
this.hashFunction = hashFunction;
this.channelBinding = channelBinding;
}
public static Mechanism of(final String mechanism) {
final int first = mechanism.indexOf('-');
final int last = mechanism.lastIndexOf('-');
if (last <= first || mechanism.length() <= last) {
throw new IllegalArgumentException("Not a valid HashedToken name");
}
if (mechanism.substring(0, first).equals(PREFIX)) {
final String hashFunction = mechanism.substring(first + 1, last);
final String cbShortName = mechanism.substring(last + 1);
final ChannelBinding channelBinding =
ChannelBinding.SHORT_NAMES.inverse().get(cbShortName);
if (channelBinding == null) {
throw new IllegalArgumentException("Unknown channel binding " + cbShortName);
}
return new Mechanism(hashFunction, channelBinding);
} else {
throw new IllegalArgumentException("HashedToken name does not start with HT");
}
}
public static Mechanism ofOrNull(final String mechanism) {
try {
return mechanism == null ? null : of(mechanism);
} catch (final IllegalArgumentException e) {
return null;
}
}
public static Multimap<String, ChannelBinding> of(final Collection<String> mechanisms) {
final ImmutableMultimap.Builder<String, ChannelBinding> builder =
ImmutableMultimap.builder();
for (final String name : mechanisms) {
try {
final Mechanism mechanism = Mechanism.of(name);
builder.put(mechanism.hashFunction, mechanism.channelBinding);
} catch (final IllegalArgumentException ignored) {
}
}
return builder.build();
}
public static Mechanism best(
final Collection<String> mechanisms, final SSLSockets.Version sslVersion) {
final Multimap<String, ChannelBinding> multimap = of(mechanisms);
for (final String hashFunction : HASH_FUNCTIONS) {
final Collection<ChannelBinding> channelBindings = multimap.get(hashFunction);
if (channelBindings.isEmpty()) {
continue;
}
final ChannelBinding cb = ChannelBinding.best(channelBindings, sslVersion);
return new Mechanism(hashFunction, cb);
}
return null;
}
@NotNull
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("hashFunction", hashFunction)
.add("channelBinding", channelBinding)
.toString();
}
public String name() {
return String.format(
"%s-%s-%s",
PREFIX, hashFunction, ChannelBinding.SHORT_NAMES.get(channelBinding));
}
}
public ChannelBinding getChannelBinding() {
return this.channelBinding;
}
}

View file

@ -0,0 +1,26 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class HashedTokenSha256 extends HashedToken {
public HashedTokenSha256(
final Account account,
final Credential credential,
final ChannelBinding channelBinding) {
super(account, credential, channelBinding);
}
@Override
protected HashFunction getHashFunction(final byte[] key) {
return Hashing.hmacSha256(key);
}
@Override
public Mechanism getTokenMechanism() {
return new Mechanism("SHA-256", channelBinding);
}
}

View file

@ -0,0 +1,26 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class HashedTokenSha512 extends HashedToken {
public HashedTokenSha512(
final Account account,
final Credential credential,
final ChannelBinding channelBinding) {
super(account, credential, channelBinding);
}
@Override
protected HashFunction getHashFunction(final byte[] key) {
return Hashing.hmacSha512(key);
}
@Override
public Mechanism getTokenMechanism() {
return new Mechanism("SHA-512", this.channelBinding);
}
}

View file

@ -0,0 +1,36 @@
package im.conversations.android.xmpp.sasl;
import android.util.Base64;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import java.nio.charset.Charset;
import javax.net.ssl.SSLSocket;
public class Plain extends SaslMechanism {
public static final String MECHANISM = "PLAIN";
public Plain(final Account account, final Credential credential) {
super(account, credential);
}
public static String getMessage(String username, String password) {
final String message = '\u0000' + username + '\u0000' + password;
return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
}
@Override
public int getPriority() {
return 10;
}
@Override
public String getMechanism() {
return MECHANISM;
}
@Override
public String getClientFirstMessage(final SSLSocket sslSocket) {
return getMessage(account.address.getEscapedLocal(), credential.password);
}
}

View file

@ -0,0 +1,236 @@
package im.conversations.android.xmpp.sasl;
import android.util.Log;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.Collections2;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.SSLSockets;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import java.util.Collection;
import java.util.Collections;
import javax.net.ssl.SSLSocket;
public abstract class SaslMechanism {
protected final Account account;
protected final Credential credential;
protected SaslMechanism(final Account account, final Credential credential) {
this.account = account;
this.credential = credential;
}
public static String namespace(final Version version) {
if (version == Version.SASL) {
return Namespace.SASL;
} else {
return Namespace.SASL_2;
}
}
/**
* The priority is used to pin the authentication mechanism. If authentication fails, it MAY be
* retried with another mechanism of the same priority, but MUST NOT be tried with a mechanism
* of lower priority (to prevent downgrade attacks).
*
* @return An arbitrary int representing the priority
*/
public abstract int getPriority();
public abstract String getMechanism();
public String getClientFirstMessage(final SSLSocket sslSocket) {
return "";
}
public String getResponse(final String challenge, final SSLSocket sslSocket)
throws AuthenticationException {
return "";
}
public static Collection<String> mechanisms(final Element authElement) {
if (authElement == null) {
return Collections.emptyList();
}
return Collections2.transform(
Collections2.filter(
authElement.getChildren(),
c -> c != null && "mechanism".equals(c.getName())),
c -> c == null ? null : c.getContent());
}
protected enum State {
INITIAL,
AUTH_TEXT_SENT,
RESPONSE_SENT,
VALID_SERVER_RESPONSE,
}
public enum Version {
SASL,
SASL_2;
public static Version of(final Element element) {
switch (Strings.nullToEmpty(element.getNamespace())) {
case Namespace.SASL:
return SASL;
case Namespace.SASL_2:
return SASL_2;
default:
throw new IllegalArgumentException("Unrecognized SASL namespace");
}
}
}
public static class AuthenticationException extends Exception {
public AuthenticationException(final String message) {
super(message);
}
public AuthenticationException(final Exception inner) {
super(inner);
}
public AuthenticationException(final String message, final Exception exception) {
super(message, exception);
}
}
public static class InvalidStateException extends AuthenticationException {
public InvalidStateException(final String message) {
super(message);
}
public InvalidStateException(final State state) {
this("Invalid state: " + state.toString());
}
}
public static final class Factory {
private final Account account;
private final Credential credential;
public Factory(final Account account, final Credential credential) {
this.account = account;
this.credential = credential;
}
private SaslMechanism of(
final Collection<String> mechanisms, final ChannelBinding channelBinding) {
Preconditions.checkNotNull(channelBinding, "Use ChannelBinding.NONE instead of null");
if (mechanisms.contains(External.MECHANISM) && credential.privateKeyAlias != null) {
return new External(account);
} else if (mechanisms.contains(ScramSha512Plus.MECHANISM)
&& channelBinding != ChannelBinding.NONE) {
return new ScramSha512Plus(account, credential, channelBinding);
} else if (mechanisms.contains(ScramSha256Plus.MECHANISM)
&& channelBinding != ChannelBinding.NONE) {
return new ScramSha256Plus(account, credential, channelBinding);
} else if (mechanisms.contains(ScramSha1Plus.MECHANISM)
&& channelBinding != ChannelBinding.NONE) {
return new ScramSha1Plus(account, credential, channelBinding);
} else if (mechanisms.contains(ScramSha512.MECHANISM)) {
return new ScramSha512(account, credential);
} else if (mechanisms.contains(ScramSha256.MECHANISM)) {
return new ScramSha256(account, credential);
} else if (mechanisms.contains(ScramSha1.MECHANISM)) {
return new ScramSha1(account, credential);
} else if (mechanisms.contains(Plain.MECHANISM)) {
return new Plain(account, credential);
} else if (mechanisms.contains(DigestMd5.MECHANISM)) {
return new DigestMd5(account, credential);
} else if (mechanisms.contains(Anonymous.MECHANISM)) {
return new Anonymous(account);
} else {
return null;
}
}
public SaslMechanism of(
final Collection<String> mechanisms,
final Collection<ChannelBinding> bindings,
final Version version,
final SSLSockets.Version sslVersion) {
final HashedToken fastMechanism = getFastMechanism();
if (version == Version.SASL_2 && fastMechanism != null) {
return fastMechanism;
}
final ChannelBinding channelBinding = ChannelBinding.best(bindings, sslVersion);
return of(mechanisms, channelBinding);
}
public SaslMechanism of(final String mechanism, final ChannelBinding channelBinding) {
return of(Collections.singleton(mechanism), channelBinding);
}
public HashedToken getFastMechanism() {
final HashedToken.Mechanism fastMechanism =
HashedToken.Mechanism.ofOrNull(credential.fastMechanism);
final String token = credential.fastToken;
if (fastMechanism == null || Strings.isNullOrEmpty(token)) {
return null;
}
if (fastMechanism.hashFunction.equals("SHA-256")) {
return new HashedTokenSha256(account, credential, fastMechanism.channelBinding);
} else if (fastMechanism.hashFunction.equals("SHA-512")) {
return new HashedTokenSha512(account, credential, fastMechanism.channelBinding);
} else {
return null;
}
}
private SaslMechanism getPinnedMechanism() {
final String mechanism = Strings.nullToEmpty(credential.pinnedMechanism);
final ChannelBinding channelBinding =
ChannelBinding.get(credential.pinnedChannelBinding);
return this.of(mechanism, channelBinding);
}
public SaslMechanism getQuickStartMechanism() {
final HashedToken hashedTokenMechanism = getFastMechanism();
if (hashedTokenMechanism != null) {
return hashedTokenMechanism;
}
return getPinnedMechanism();
}
public int getPinnedMechanismPriority() {
final SaslMechanism saslMechanism = getPinnedMechanism();
if (saslMechanism == null) {
return Integer.MIN_VALUE;
} else {
return saslMechanism.getPriority();
}
}
}
public static SaslMechanism ensureAvailable(
final SaslMechanism mechanism, final SSLSockets.Version sslVersion) {
if (mechanism instanceof ChannelBindingMechanism) {
final ChannelBinding cb = ((ChannelBindingMechanism) mechanism).getChannelBinding();
if (ChannelBinding.isAvailable(cb, sslVersion)) {
return mechanism;
} else {
Log.d(
Config.LOGTAG,
"pinned channel binding method " + cb + " no longer available");
return null;
}
} else {
return mechanism;
}
}
public static boolean hashedToken(final SaslMechanism saslMechanism) {
return saslMechanism instanceof HashedToken;
}
public static boolean pin(final SaslMechanism saslMechanism) {
return !hashedToken(saslMechanism);
}
}

View file

@ -0,0 +1,318 @@
package im.conversations.android.xmpp.sasl;
import android.util.Base64;
import com.google.common.base.CaseFormat;
import com.google.common.base.Objects;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.hash.HashFunction;
import eu.siacs.conversations.utils.CryptoHelper;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.util.concurrent.ExecutionException;
import javax.crypto.SecretKey;
import javax.net.ssl.SSLSocket;
abstract class ScramMechanism extends SaslMechanism {
public static final SecretKey EMPTY_KEY =
new SecretKey() {
@Override
public String getAlgorithm() {
return "HMAC";
}
@Override
public String getFormat() {
return "RAW";
}
@Override
public byte[] getEncoded() {
return new byte[0];
}
};
private static final byte[] CLIENT_KEY_BYTES = "Client Key".getBytes();
private static final byte[] SERVER_KEY_BYTES = "Server Key".getBytes();
private static final Cache<CacheKey, KeyPair> CACHE =
CacheBuilder.newBuilder().maximumSize(10).build();
protected final ChannelBinding channelBinding;
private final String gs2Header;
private final String clientNonce;
protected State state = State.INITIAL;
private String clientFirstMessageBare;
private byte[] serverSignature = null;
ScramMechanism(
final Account account,
final Credential credential,
final ChannelBinding channelBinding) {
super(account, credential);
this.channelBinding = channelBinding;
if (channelBinding == ChannelBinding.NONE) {
// TODO this needs to be changed to "y,," for the scram internal down grade protection
// but we might risk compatibility issues if the server supports a binding that we dont
// support
this.gs2Header = "n,,";
} else {
this.gs2Header =
String.format(
"p=%s,,",
CaseFormat.UPPER_UNDERSCORE
.converterTo(CaseFormat.LOWER_HYPHEN)
.convert(channelBinding.toString()));
}
// This nonce should be different for each authentication attempt.
this.clientNonce = CryptoHelper.random(100);
clientFirstMessageBare = "";
}
protected abstract HashFunction getHMac(final byte[] key);
protected abstract HashFunction getDigest();
private KeyPair getKeyPair(final String password, final String salt, final int iterations)
throws ExecutionException {
return CACHE.get(
new CacheKey(getMechanism(), password, salt, iterations),
() -> {
final byte[] saltedPassword, serverKey, clientKey;
saltedPassword =
hi(
password.getBytes(),
Base64.decode(salt, Base64.DEFAULT),
iterations);
serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
return new KeyPair(clientKey, serverKey);
});
}
private byte[] hmac(final byte[] key, final byte[] input) throws InvalidKeyException {
return getHMac(key).hashBytes(input).asBytes();
}
private byte[] digest(final byte[] bytes) {
return getDigest().hashBytes(bytes).asBytes();
}
/*
* Hi() is, essentially, PBKDF2 [RFC2898] with HMAC() as the
* pseudorandom function (PRF) and with dkLen == output length of
* HMAC() == output length of H().
*/
private byte[] hi(final byte[] key, final byte[] salt, final int iterations)
throws InvalidKeyException {
byte[] u = hmac(key, CryptoHelper.concatenateByteArrays(salt, CryptoHelper.ONE));
byte[] out = u.clone();
for (int i = 1; i < iterations; i++) {
u = hmac(key, u);
for (int j = 0; j < u.length; j++) {
out[j] ^= u[j];
}
}
return out;
}
@Override
public String getClientFirstMessage(final SSLSocket sslSocket) {
if (clientFirstMessageBare.isEmpty() && state == State.INITIAL) {
clientFirstMessageBare =
"n="
+ CryptoHelper.saslEscape(
CryptoHelper.saslPrep(account.address.getEscapedLocal()))
+ ",r="
+ this.clientNonce;
state = State.AUTH_TEXT_SENT;
}
return Base64.encodeToString(
(gs2Header + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
Base64.NO_WRAP);
}
@Override
public String getResponse(final String challenge, final SSLSocket socket)
throws AuthenticationException {
switch (state) {
case AUTH_TEXT_SENT:
if (challenge == null) {
throw new AuthenticationException("challenge can not be null");
}
byte[] serverFirstMessage;
try {
serverFirstMessage = Base64.decode(challenge, Base64.DEFAULT);
} catch (IllegalArgumentException e) {
throw new AuthenticationException("Unable to decode server challenge", e);
}
final Tokenizer tokenizer = new Tokenizer(serverFirstMessage);
String nonce = "";
int iterationCount = -1;
String salt = "";
for (final String token : tokenizer) {
if (token.charAt(1) == '=') {
switch (token.charAt(0)) {
case 'i':
try {
iterationCount = Integer.parseInt(token.substring(2));
} catch (final NumberFormatException e) {
throw new AuthenticationException(e);
}
break;
case 's':
salt = token.substring(2);
break;
case 'r':
nonce = token.substring(2);
break;
case 'm':
/*
* RFC 5802:
* m: This attribute is reserved for future extensibility. In this
* version of SCRAM, its presence in a client or a server message
* MUST cause authentication failure when the attribute is parsed by
* the other end.
*/
throw new AuthenticationException(
"Server sent reserved token: `m'");
}
}
}
if (iterationCount < 0) {
throw new AuthenticationException("Server did not send iteration count");
}
if (nonce.isEmpty() || !nonce.startsWith(clientNonce)) {
throw new AuthenticationException(
"Server nonce does not contain client nonce: " + nonce);
}
if (salt.isEmpty()) {
throw new AuthenticationException("Server sent empty salt");
}
final byte[] channelBindingData = getChannelBindingData(socket);
final int gs2Len = this.gs2Header.getBytes().length;
final byte[] cMessage = new byte[gs2Len + channelBindingData.length];
System.arraycopy(this.gs2Header.getBytes(), 0, cMessage, 0, gs2Len);
System.arraycopy(
channelBindingData, 0, cMessage, gs2Len, channelBindingData.length);
final String clientFinalMessageWithoutProof =
"c=" + Base64.encodeToString(cMessage, Base64.NO_WRAP) + ",r=" + nonce;
final byte[] authMessage =
(clientFirstMessageBare
+ ','
+ new String(serverFirstMessage)
+ ','
+ clientFinalMessageWithoutProof)
.getBytes();
final KeyPair keys;
try {
keys =
getKeyPair(
CryptoHelper.saslPrep(credential.password),
salt,
iterationCount);
} catch (ExecutionException e) {
throw new AuthenticationException("Invalid keys generated");
}
final byte[] clientSignature;
try {
serverSignature = hmac(keys.serverKey, authMessage);
final byte[] storedKey = digest(keys.clientKey);
clientSignature = hmac(storedKey, authMessage);
} catch (final InvalidKeyException e) {
throw new AuthenticationException(e);
}
final byte[] clientProof = new byte[keys.clientKey.length];
if (clientSignature.length < keys.clientKey.length) {
throw new AuthenticationException(
"client signature was shorter than clientKey");
}
for (int i = 0; i < clientProof.length; i++) {
clientProof[i] = (byte) (keys.clientKey[i] ^ clientSignature[i]);
}
final String clientFinalMessage =
clientFinalMessageWithoutProof
+ ",p="
+ Base64.encodeToString(clientProof, Base64.NO_WRAP);
state = State.RESPONSE_SENT;
return Base64.encodeToString(clientFinalMessage.getBytes(), Base64.NO_WRAP);
case RESPONSE_SENT:
try {
final String clientCalculatedServerFinalMessage =
"v=" + Base64.encodeToString(serverSignature, Base64.NO_WRAP);
if (!clientCalculatedServerFinalMessage.equals(
new String(Base64.decode(challenge, Base64.DEFAULT)))) {
throw new Exception();
}
state = State.VALID_SERVER_RESPONSE;
return "";
} catch (Exception e) {
throw new AuthenticationException(
"Server final message does not match calculated final message");
}
default:
throw new InvalidStateException(state);
}
}
protected byte[] getChannelBindingData(final SSLSocket sslSocket)
throws AuthenticationException {
if (this.channelBinding == ChannelBinding.NONE) {
return new byte[0];
}
throw new AssertionError("getChannelBindingData needs to be overwritten");
}
private static class CacheKey {
final String algorithm;
final String password;
final String salt;
final int iterations;
private CacheKey(String algorithm, String password, String salt, int iterations) {
this.algorithm = algorithm;
this.password = password;
this.salt = salt;
this.iterations = iterations;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CacheKey cacheKey = (CacheKey) o;
return iterations == cacheKey.iterations
&& Objects.equal(algorithm, cacheKey.algorithm)
&& Objects.equal(password, cacheKey.password)
&& Objects.equal(salt, cacheKey.salt);
}
@Override
public int hashCode() {
return Objects.hashCode(algorithm, password, salt, iterations);
}
}
private static class KeyPair {
final byte[] clientKey;
final byte[] serverKey;
KeyPair(final byte[] clientKey, final byte[] serverKey) {
this.clientKey = clientKey;
this.serverKey = serverKey;
}
}
}

View file

@ -0,0 +1,24 @@
package im.conversations.android.xmpp.sasl;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
import javax.net.ssl.SSLSocket;
public abstract class ScramPlusMechanism extends ScramMechanism implements ChannelBindingMechanism {
ScramPlusMechanism(
Account account, final Credential credential, ChannelBinding channelBinding) {
super(account, credential, channelBinding);
}
@Override
protected byte[] getChannelBindingData(final SSLSocket sslSocket)
throws AuthenticationException {
return ChannelBindingMechanism.getChannelBindingData(sslSocket, this.channelBinding);
}
@Override
public ChannelBinding getChannelBinding() {
return this.channelBinding;
}
}

View file

@ -0,0 +1,37 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class ScramSha1 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-1";
public ScramSha1(final Account account, final Credential credential) {
super(account, credential, ChannelBinding.NONE);
}
@Override
protected HashFunction getHMac(final byte[] key) {
return (key == null || key.length == 0)
? Hashing.hmacSha1(EMPTY_KEY)
: Hashing.hmacSha1(key);
}
@Override
protected HashFunction getDigest() {
return Hashing.sha1();
}
@Override
public int getPriority() {
return 20;
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -0,0 +1,38 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class ScramSha1Plus extends ScramPlusMechanism {
public static final String MECHANISM = "SCRAM-SHA-1-PLUS";
public ScramSha1Plus(
final Account account, Credential credential, final ChannelBinding channelBinding) {
super(account, credential, channelBinding);
}
@Override
protected HashFunction getHMac(final byte[] key) {
return (key == null || key.length == 0)
? Hashing.hmacSha1(EMPTY_KEY)
: Hashing.hmacSha1(key);
}
@Override
protected HashFunction getDigest() {
return Hashing.sha1();
}
@Override
public int getPriority() {
return 35; // higher than SCRAM-SHA512 (30)
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -0,0 +1,37 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class ScramSha256 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-256";
public ScramSha256(final Account account, final Credential credential) {
super(account, credential, ChannelBinding.NONE);
}
@Override
protected HashFunction getHMac(final byte[] key) {
return (key == null || key.length == 0)
? Hashing.hmacSha256(EMPTY_KEY)
: Hashing.hmacSha256(key);
}
@Override
protected HashFunction getDigest() {
return Hashing.sha256();
}
@Override
public int getPriority() {
return 25;
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -0,0 +1,40 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class ScramSha256Plus extends ScramPlusMechanism {
public static final String MECHANISM = "SCRAM-SHA-256-PLUS";
public ScramSha256Plus(
final Account account,
final Credential credential,
final ChannelBinding channelBinding) {
super(account, credential, channelBinding);
}
@Override
protected HashFunction getHMac(final byte[] key) {
return (key == null || key.length == 0)
? Hashing.hmacSha256(EMPTY_KEY)
: Hashing.hmacSha256(key);
}
@Override
protected HashFunction getDigest() {
return Hashing.sha256();
}
@Override
public int getPriority() {
return 40;
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -0,0 +1,37 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class ScramSha512 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-512";
public ScramSha512(final Account account, final Credential credential) {
super(account, credential, ChannelBinding.NONE);
}
@Override
protected HashFunction getHMac(final byte[] key) {
return (key == null || key.length == 0)
? Hashing.hmacSha512(EMPTY_KEY)
: Hashing.hmacSha512(key);
}
@Override
protected HashFunction getDigest() {
return Hashing.sha512();
}
@Override
public int getPriority() {
return 30;
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -0,0 +1,40 @@
package im.conversations.android.xmpp.sasl;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Credential;
public class ScramSha512Plus extends ScramPlusMechanism {
public static final String MECHANISM = "SCRAM-SHA-512-PLUS";
public ScramSha512Plus(
final Account account,
final Credential credential,
final ChannelBinding channelBinding) {
super(account, credential, channelBinding);
}
@Override
protected HashFunction getHMac(final byte[] key) {
return (key == null || key.length == 0)
? Hashing.hmacSha512(EMPTY_KEY)
: Hashing.hmacSha512(key);
}
@Override
protected HashFunction getDigest() {
return Hashing.sha512();
}
@Override
public int getPriority() {
return 45;
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -0,0 +1,77 @@
package im.conversations.android.xmpp.sasl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
/** A tokenizer for GS2 header strings */
public final class Tokenizer implements Iterator<String>, Iterable<String> {
private final List<String> parts;
private int index;
public Tokenizer(final byte[] challenge) {
final String challengeString = new String(challenge);
parts = new ArrayList<>(Arrays.asList(challengeString.split(",")));
// Trim parts.
for (int i = 0; i < parts.size(); i++) {
parts.set(i, parts.get(i).trim());
}
index = 0;
}
/**
* Returns true if there is at least one more element, false otherwise.
*
* @see #next
*/
@Override
public boolean hasNext() {
return parts.size() != index + 1;
}
/**
* Returns the next object and advances the iterator.
*
* @return the next object.
* @throws java.util.NoSuchElementException if there are no more elements.
* @see #hasNext
*/
@Override
public String next() {
if (hasNext()) {
return parts.get(index++);
} else {
throw new NoSuchElementException("No such element. Size is: " + parts.size());
}
}
/**
* Removes the last object returned by {@code next} from the collection. This method can only be
* called once between each call to {@code next}.
*
* @throws UnsupportedOperationException if removing is not supported by the collection being
* iterated.
* @throws IllegalStateException if {@code next} has not been called, or {@code remove} has
* already been called after the last call to {@code next}.
*/
@Override
public void remove() {
if (index <= 0) {
throw new IllegalStateException(
"You can't delete an element before first next() method call");
}
parts.remove(--index);
}
/**
* Returns an {@link java.util.Iterator} for the elements in this object.
*
* @return An {@code Iterator} instance.
*/
@Override
public Iterator<String> iterator() {
return parts.iterator();
}
}