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:
parent
94dde9f433
commit
7ee3e07946
|
@ -53,6 +53,8 @@ dependencies {
|
|||
annotationProcessor "androidx.room:room-compiler:$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
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c78cb993428558b863fd91c46b608926",
|
||||
"identityHash": "4a70ff0733436f5a2a08e7abb8e6cc95",
|
||||
"entities": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
|
@ -38,6 +38,30 @@
|
|||
"affinity": "INTEGER",
|
||||
"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",
|
||||
"columnName": "rosterVersion",
|
||||
|
@ -830,7 +854,7 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
|
@ -903,6 +927,12 @@
|
|||
"columnName": "mucUserJid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mucUserSelf",
|
||||
"columnName": "mucUserSelf",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
|
@ -1159,7 +1189,7 @@
|
|||
"views": [],
|
||||
"setupQueries": [
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -74,6 +74,7 @@
|
|||
|
||||
|
||||
<application
|
||||
android:name="im.conversations.android.Conversations"
|
||||
android:allowBackup="true"
|
||||
android:appCategory="social"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
|
|
|
@ -38,18 +38,18 @@ import android.preference.PreferenceManager;
|
|||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.util.SparseArray;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.common.base.Charsets;
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.common.io.CharStreams;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
import eu.siacs.conversations.R;
|
||||
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;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
|
@ -73,44 +73,48 @@ import java.util.Locale;
|
|||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
import eu.siacs.conversations.R;
|
||||
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;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* A X509 trust manager implementation which asks the user about invalid
|
||||
* certificates and memorizes their decision.
|
||||
* <p>
|
||||
* The certificate validity is checked using the system default X509
|
||||
* TrustManager, creating a query Dialog if the check fails.
|
||||
* <p>
|
||||
* <b>WARNING:</b> This only works if a dedicated thread is used for
|
||||
* opening sockets!
|
||||
* A X509 trust manager implementation which asks the user about invalid certificates and memorizes
|
||||
* their decision.
|
||||
*
|
||||
* <p>The certificate validity is checked using the system default X509 TrustManager, creating a
|
||||
* query Dialog if the check fails.
|
||||
*
|
||||
* <p><b>WARNING:</b> This only works if a dedicated thread is used for opening sockets!
|
||||
*/
|
||||
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";
|
||||
public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId";
|
||||
public final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert";
|
||||
public final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId";
|
||||
final static 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_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");
|
||||
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 final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName());
|
||||
static final String DECISION_INTENT = "de.duenndns.ssl.DECISION";
|
||||
public static final String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId";
|
||||
public static final String DECISION_INTENT_CERT = DECISION_INTENT + ".cert";
|
||||
public static final String DECISION_TITLE_ID = DECISION_INTENT + ".titleId";
|
||||
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_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");
|
||||
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_FILE = "KeyStore.bks";
|
||||
private static int decisionId = 0;
|
||||
|
@ -125,19 +129,32 @@ public class MemorizingTrustManager {
|
|||
private X509TrustManager appTrustManager;
|
||||
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.
|
||||
* <p>
|
||||
* You need to supply the application context. This has to be one of:
|
||||
* - Application
|
||||
* - Activity
|
||||
* - Service
|
||||
* <p>
|
||||
* The context is used for file management, to display the dialog /
|
||||
* notification and for obtaining translated strings.
|
||||
* Creates an instance of the MemorizingTrustManager class that falls back to a custom
|
||||
* TrustManager.
|
||||
*
|
||||
* <p>You need to supply the application context. This has to be one of: - Application -
|
||||
* Activity - Service
|
||||
*
|
||||
* <p>The context is used for file management, to display the dialog / notification and for
|
||||
* obtaining translated strings.
|
||||
*
|
||||
* @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) {
|
||||
init(m);
|
||||
|
@ -147,14 +164,12 @@ public class MemorizingTrustManager {
|
|||
|
||||
/**
|
||||
* 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:
|
||||
* - Application
|
||||
* - Activity
|
||||
* - Service
|
||||
* <p>
|
||||
* The context is used for file management, to display the dialog /
|
||||
* notification and for obtaining translated strings.
|
||||
*
|
||||
* <p>You need to supply the application context. This has to be one of: - Application -
|
||||
* Activity - Service
|
||||
*
|
||||
* <p>The context is used for file management, to display the dialog / notification and for
|
||||
* obtaining translated strings.
|
||||
*
|
||||
* @param m Context for the application.
|
||||
*/
|
||||
|
@ -165,15 +180,16 @@ public class MemorizingTrustManager {
|
|||
}
|
||||
|
||||
private static boolean isIp(final String server) {
|
||||
return server != null && (
|
||||
PATTERN_IPV4.matcher(server).matches()
|
||||
return server != null
|
||||
&& (PATTERN_IPV4.matcher(server).matches()
|
||||
|| PATTERN_IPV6.matcher(server).matches()
|
||||
|| PATTERN_IPV6_6HEX4DEC.matcher(server).matches()
|
||||
|| PATTERN_IPV6_HEX4DECCOMPRESSED.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;
|
||||
try {
|
||||
md = MessageDigest.getInstance(digest);
|
||||
|
@ -188,8 +204,7 @@ public class MemorizingTrustManager {
|
|||
StringBuffer si = new StringBuffer();
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
si.append(String.format("%02x", data[i]));
|
||||
if (i < data.length - 1)
|
||||
si.append(":");
|
||||
if (i < data.length - 1) si.append(":");
|
||||
}
|
||||
return si.toString();
|
||||
}
|
||||
|
@ -223,7 +238,8 @@ public class MemorizingTrustManager {
|
|||
void init(final Context m) {
|
||||
master = m;
|
||||
masterHandler = new Handler(m.getMainLooper());
|
||||
notificationManager = (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager =
|
||||
(NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
Application app;
|
||||
if (m instanceof Application) {
|
||||
|
@ -233,7 +249,8 @@ public class MemorizingTrustManager {
|
|||
} else if (m instanceof AppCompatActivity) {
|
||||
app = ((AppCompatActivity) m).getApplication();
|
||||
} 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);
|
||||
keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE);
|
||||
|
@ -260,12 +277,9 @@ public class MemorizingTrustManager {
|
|||
/**
|
||||
* Removes the given certificate from MTMs key store.
|
||||
*
|
||||
* <p>
|
||||
* <b>WARNING</b>: this does not immediately invalidate the certificate. It is
|
||||
* well possible that (a) data is transmitted over still existing connections or
|
||||
* (b) new connections are created using TLS renegotiation, without a new cert
|
||||
* check.
|
||||
* </p>
|
||||
* <p><b>WARNING</b>: this does not immediately invalidate the certificate. It is well possible
|
||||
* that (a) data is transmitted over still existing connections or (b) new connections are
|
||||
* created using TLS renegotiation, without a new cert check.
|
||||
*
|
||||
* @param alias the certificate's alias as returned by {@link #getCertificates()}.
|
||||
* @throws KeyStoreException if the certificate could not be deleted.
|
||||
|
@ -361,45 +375,60 @@ public class MemorizingTrustManager {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private void checkCertTrusted(X509Certificate[] chain, String authType, String domain, boolean isServer, boolean interactive)
|
||||
private void checkCertTrusted(
|
||||
X509Certificate[] chain,
|
||||
String authType,
|
||||
String domain,
|
||||
boolean isServer,
|
||||
boolean interactive)
|
||||
throws CertificateException {
|
||||
LOGGER.log(Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")");
|
||||
LOGGER.log(
|
||||
Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")");
|
||||
try {
|
||||
LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager");
|
||||
if (isServer)
|
||||
appTrustManager.checkServerTrusted(chain, authType);
|
||||
else
|
||||
appTrustManager.checkClientTrusted(chain, authType);
|
||||
if (isServer) appTrustManager.checkServerTrusted(chain, authType);
|
||||
else appTrustManager.checkClientTrusted(chain, authType);
|
||||
} catch (final CertificateException ae) {
|
||||
LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae);
|
||||
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;
|
||||
}
|
||||
try {
|
||||
if (defaultTrustManager == null)
|
||||
throw ae;
|
||||
if (defaultTrustManager == null) throw ae;
|
||||
LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager");
|
||||
if (isServer)
|
||||
defaultTrustManager.checkServerTrusted(chain, authType);
|
||||
else
|
||||
defaultTrustManager.checkClientTrusted(chain, authType);
|
||||
if (isServer) defaultTrustManager.checkServerTrusted(chain, authType);
|
||||
else defaultTrustManager.checkClientTrusted(chain, authType);
|
||||
} catch (final CertificateException e) {
|
||||
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(master);
|
||||
final boolean trustSystemCAs = !preferences.getBoolean("dont_trust_system_cas", false);
|
||||
if (domain != null && isServer && trustSystemCAs && !isIp(domain) && !domain.endsWith(".onion")) {
|
||||
final SharedPreferences preferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(master);
|
||||
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 List<String> fingerprints = getPoshFingerprints(domain);
|
||||
if (hash != null && fingerprints.size() > 0) {
|
||||
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;
|
||||
} 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()) {
|
||||
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) {
|
||||
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);
|
||||
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 {
|
||||
final List<String> results = new ArrayList<>();
|
||||
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);
|
||||
int expires = jsonObject.getInt("expires");
|
||||
if (expires <= 0) {
|
||||
|
@ -489,7 +526,8 @@ public class MemorizingTrustManager {
|
|||
final File file = getPoshCacheFile(domain);
|
||||
try {
|
||||
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);
|
||||
long expires = jsonObject.getLong("expires");
|
||||
long expiresIn = expires - System.currentTimeMillis();
|
||||
|
@ -514,7 +552,9 @@ public class MemorizingTrustManager {
|
|||
}
|
||||
|
||||
private X509Certificate[] getAcceptedIssuers() {
|
||||
return defaultTrustManager == null ? new X509Certificate[0] : defaultTrustManager.getAcceptedIssuers();
|
||||
return defaultTrustManager == null
|
||||
? new X509Certificate[0]
|
||||
: defaultTrustManager.getAcceptedIssuers();
|
||||
}
|
||||
|
||||
private int createDecisionId(MTMDecision d) {
|
||||
|
@ -527,7 +567,8 @@ public class MemorizingTrustManager {
|
|||
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");
|
||||
if (showValidFor) {
|
||||
|
@ -564,8 +605,7 @@ public class MemorizingTrustManager {
|
|||
// not found", so we use string comparison.
|
||||
if (NO_TRUST_ANCHOR.equals(e.getMessage())) {
|
||||
si.append(master.getString(R.string.mtm_trust_anchor));
|
||||
} else
|
||||
si.append(e.getLocalizedMessage());
|
||||
} else si.append(e.getLocalizedMessage());
|
||||
si.append("\n");
|
||||
}
|
||||
si.append("\n");
|
||||
|
@ -593,7 +633,8 @@ public class MemorizingTrustManager {
|
|||
MTMDecision choice = new MTMDecision();
|
||||
final int myId = createDecisionId(choice);
|
||||
|
||||
masterHandler.post(new Runnable() {
|
||||
masterHandler.post(
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
Intent ni = new Intent(master, MemorizingActivity.class);
|
||||
ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
@ -661,7 +702,8 @@ public class MemorizingTrustManager {
|
|||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
|
@ -675,7 +717,6 @@ public class MemorizingTrustManager {
|
|||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return MemorizingTrustManager.this.getAcceptedIssuers();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class InteractiveMemorizingTrustManager implements X509TrustManager {
|
||||
|
@ -686,7 +727,8 @@ public class MemorizingTrustManager {
|
|||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,11 @@ package eu.siacs.conversations.utils;
|
|||
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
import eu.siacs.conversations.entities.Account;
|
||||
import eu.siacs.conversations.xmpp.Jid;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
@ -17,29 +15,26 @@ import java.util.Arrays;
|
|||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
import eu.siacs.conversations.entities.Account;
|
||||
import org.conscrypt.Conscrypt;
|
||||
|
||||
public class SSLSockets {
|
||||
|
||||
public static void setSecurity(final SSLSocket sslSocket) {
|
||||
final String[] supportProtocols;
|
||||
final Collection<String> supportedProtocols = new LinkedList<>(
|
||||
Arrays.asList(sslSocket.getSupportedProtocols()));
|
||||
final Collection<String> supportedProtocols =
|
||||
new LinkedList<>(Arrays.asList(sslSocket.getSupportedProtocols()));
|
||||
supportedProtocols.remove("SSLv3");
|
||||
supportProtocols = supportedProtocols.toArray(new String[0]);
|
||||
|
||||
sslSocket.setEnabledProtocols(supportProtocols);
|
||||
|
||||
final String[] cipherSuites = CryptoHelper.getOrderedCipherSuites(
|
||||
sslSocket.getSupportedCipherSuites());
|
||||
final String[] cipherSuites =
|
||||
CryptoHelper.getOrderedCipherSuites(sslSocket.getSupportedCipherSuites());
|
||||
if (cipherSuites.length > 0) {
|
||||
sslSocket.setEnabledCipherSuites(cipherSuites);
|
||||
}
|
||||
|
@ -70,7 +65,8 @@ public class SSLSockets {
|
|||
socket.setSSLParameters(parameters);
|
||||
}
|
||||
|
||||
private static void setApplicationProtocolReflection(final SSLSocket socket, final String protocol) {
|
||||
private static void setApplicationProtocolReflection(
|
||||
final SSLSocket socket, final String protocol) {
|
||||
try {
|
||||
final Method method = socket.getClass().getMethod("setAlpnProtocols", byte[].class);
|
||||
// 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[] lengthPrefixedProtocols = new byte[protocolUTF8Bytes.length + 1];
|
||||
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});
|
||||
} catch (Throwable 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();
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
account.getJid().asBareJid()
|
||||
address
|
||||
+ ": protocol="
|
||||
+ session.getProtocol()
|
||||
+ " cipher="
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
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.Map.Entry;
|
||||
import java.util.Set;
|
||||
|
||||
import eu.siacs.conversations.utils.XmlHelper;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class Tag {
|
||||
public static final int NO = -1;
|
||||
|
@ -52,6 +51,13 @@ public class Tag {
|
|||
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) {
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
|
13
src/main/java/im/conversations/android/Conversations.java
Normal file
13
src/main/java/im/conversations/android/Conversations.java
Normal 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();
|
||||
}
|
||||
}
|
38
src/main/java/im/conversations/android/Uuids.java
Normal file
38
src/main/java/im/conversations/android/Uuids.java
Normal 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);
|
||||
}
|
||||
}
|
|
@ -5,6 +5,8 @@ import androidx.room.Database;
|
|||
import androidx.room.Room;
|
||||
import androidx.room.RoomDatabase;
|
||||
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.BlockedItemEntity;
|
||||
import im.conversations.android.database.entity.ChatEntity;
|
||||
|
@ -62,4 +64,8 @@ public abstract class ConversationsDatabase extends RoomDatabase {
|
|||
return INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
|
||||
public abstract PresenceDao presenceDao();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -26,6 +26,15 @@ public class AccountEntity {
|
|||
|
||||
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;
|
||||
|
||||
@Embedded public Connection connection;
|
||||
|
|
|
@ -50,4 +50,7 @@ public class PresenceEntity {
|
|||
@Nullable public MucOptions.Role mucUserRole;
|
||||
|
||||
@Nullable public Jid mucUserJid;
|
||||
|
||||
// set to true if presence has status code 110 (this means we are online)
|
||||
public boolean mucUserSelf;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
356
src/main/java/im/conversations/android/xmpp/ConnectionPool.java
Normal file
356
src/main/java/im/conversations/android/xmpp/ConnectionPool.java
Normal 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
|
||||
// won’t 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;
|
||||
}
|
||||
}
|
||||
}
|
124
src/main/java/im/conversations/android/xmpp/ConnectionState.java
Normal file
124
src/main/java/im/conversations/android/xmpp/ConnectionState.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
2805
src/main/java/im/conversations/android/xmpp/XmppConnection.java
Normal file
2805
src/main/java/im/conversations/android/xmpp/XmppConnection.java
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 -> {});
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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 "";
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
112
src/main/java/im/conversations/android/xmpp/sasl/DigestMd5.java
Normal file
112
src/main/java/im/conversations/android/xmpp/sasl/DigestMd5.java
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
36
src/main/java/im/conversations/android/xmpp/sasl/Plain.java
Normal file
36
src/main/java/im/conversations/android/xmpp/sasl/Plain.java
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 don’t
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue