/*
* Kontalk Android client
* Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kontalk.client;
import java.io.IOException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
import org.jivesoftware.smack.AbstractXMPPConnection;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.StanzaListener;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.filter.StanzaIdFilter;
import org.jivesoftware.smack.packet.IQ;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.packet.XMPPError;
import org.jivesoftware.smackx.iqregister.packet.Registration;
import org.jivesoftware.smackx.xdata.Form;
import org.jivesoftware.smackx.xdata.FormField;
import org.jivesoftware.smackx.xdata.packet.DataForm;
import org.spongycastle.openpgp.PGPException;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.util.Base64;
import org.kontalk.Log;
import org.kontalk.crypto.PGP.PGPKeyPairRing;
import org.kontalk.crypto.PersonalKey;
import org.kontalk.crypto.X509Bridge;
import org.kontalk.reporting.ReportingManager;
import org.kontalk.service.XMPPConnectionHelper;
import org.kontalk.service.XMPPConnectionHelper.ConnectionHelperListener;
import org.kontalk.service.msgcenter.PGPKeyPairRingProvider;
import org.kontalk.util.MessageUtils;
/**
* A basic worker thread for doing number validation procedures.
* It handles all the steps defined in phone number validation, from the
* validation request to the received SMS and finally the authentication token
* request.
* @author Daniele Ricci
* @version 1.0
*/
public class NumberValidator implements Runnable, ConnectionHelperListener {
@SuppressWarnings("WeakerAccess")
static final String TAG = NumberValidator.class.getSimpleName();
/** Initialization */
private static final int STEP_INIT = 0;
/** Validation step (sending phone number and waiting for SMS) */
private static final int STEP_VALIDATION = 1;
/** Requesting authentication token */
private static final int STEP_AUTH_TOKEN = 2;
/** Login test for imported key */
private static final int STEP_LOGIN_TEST = 3;
public static final int ERROR_THROTTLING = 1;
public static final int ERROR_USER_EXISTS = 2;
// from Kontalk server code
/** Challenge the user with a verification PIN sent through a SMS or a told through a phone call. */
public static final String CHALLENGE_PIN = "pin";
/** Challenge the user with a missed call from a random number and making the user guess the digits. */
public static final String CHALLENGE_MISSED_CALL = "missedcall";
/** Challenge the user with the caller ID presented in a user-initiated call to a given phone number. */
public static final String CHALLENGE_CALLER_ID = "callerid";
// default requested challenge
private static final String DEFAULT_CHALLENGE = CHALLENGE_PIN;
@SuppressWarnings("WeakerAccess")
final EndpointServer.EndpointServerProvider mServerProvider;
private final String mName;
private final String mPhone;
private boolean mForce;
private boolean mFallback;
private PersonalKey mKey;
@SuppressWarnings("WeakerAccess")
PGPKeyPairRing mKeyRing;
private X509Certificate mBridgeCert;
private String mPassphrase;
private final Object mKeyLock = new Object();
private byte[] mImportedPrivateKey;
private byte[] mImportedPublicKey;
@SuppressWarnings("WeakerAccess")
final XMPPConnectionHelper mConnector;
@SuppressWarnings("WeakerAccess")
NumberValidatorListener mListener;
@SuppressWarnings("WeakerAccess")
volatile int mStep;
private CharSequence mValidationCode;
private Thread mThread;
private HandlerThread mServiceHandler;
@SuppressWarnings("WeakerAccess")
Handler mInternalHandler;
/** This will used to store the server-indicated challenge. */
private String mServerChallenge;
public NumberValidator(Context context, EndpointServer.EndpointServerProvider serverProvider,
String name, String phone, PersonalKey key, String passphrase) {
mServerProvider = serverProvider;
mName = name;
mPhone = phone;
mKey = key;
mPassphrase = passphrase;
mConnector = new XMPPConnectionHelper(context.getApplicationContext(), mServerProvider.next(), true);
mConnector.setRetryEnabled(false);
configure();
}
private void configure() {
SmackInitializer.initializeRegistration();
}
private void unconfigure() {
SmackInitializer.deinitializeRegistration();
}
public void setKey(PersonalKey key) {
synchronized (mKeyLock) {
mKey = key;
mKeyLock.notifyAll();
}
}
public void setForce(boolean force) {
mForce = force;
}
public void setFallback(boolean fallback) {
mFallback = fallback;
}
@Override
public PGPKeyPairRingProvider getKeyPairRingProvider() {
// not supported
return null;
}
public void importKey(byte[] privateKeyData, byte[] publicKeyData) {
mImportedPrivateKey = privateKeyData;
mImportedPublicKey = publicKeyData;
}
public EndpointServer getServer() {
return mConnector.getServer();
}
public String getServerChallenge() {
return mServerChallenge;
}
public static boolean isMissedCall(String senderId) {
// very quick way to check if we are using missed call based verification
return senderId != null && senderId.endsWith("???");
}
public static int getChallengeLength(String senderId) {
int count = 0;
if (senderId != null) {
for (int i = senderId.length()-1; i >= 0; i--) {
if (senderId.charAt(i) == '?')
count++;
else
break;
}
}
return count;
}
public synchronized void start() {
if (mThread != null) throw new IllegalArgumentException("already started");
// internal handler
mServiceHandler = new HandlerThread(NumberValidator.class.getSimpleName()) {
@Override
protected void onLooperPrepared() {
mInternalHandler = new Handler(getLooper());
}
};
mServiceHandler.start();
// validator thread
mThread = new Thread(this);
mThread.start();
}
@Override
public void run() {
// aborted
if (mServiceHandler == null)
return;
try {
// begin!
if (mStep == STEP_INIT) {
synchronized (mKeyLock) {
if (mKey == null && (mImportedPrivateKey == null || mImportedPublicKey == null)) {
Log.v(TAG, "waiting for key generator");
try {
// wait endlessly?
mKeyLock.wait();
}
catch (InterruptedException e) {
mStep = STEP_INIT;
return;
}
Log.v(TAG, "key generation completed " + mKey);
}
}
// request number validation via sms
mStep = STEP_VALIDATION;
try {
initConnection();
}
catch (Exception e) {
EndpointServer server = mServerProvider.next();
if (server != null) {
Log.w(TAG, "connection error - trying next server in list", e);
// run again with new server
mStep = STEP_INIT;
mConnector.setServer(server);
run();
return;
}
else {
// last server to try, no chance for connection
throw e;
}
}
final AbstractXMPPConnection conn = mConnector.getConnection();
Stanza form = createRegistrationForm();
// setup listener for form response
conn.addAsyncStanzaListener(new StanzaListener() {
public void processPacket(Stanza packet) {
int reason = 0;
IQ iq = (IQ) packet;
// whatever we received, close the connection now
conn.disconnect();
if (iq.getType() == IQ.Type.result) {
DataForm response = iq.getExtension("x", "jabber:x:data");
if (response != null) {
// ok! message will be sent
String smsFrom = null, challenge = null,
brandImage = null, brandLink = null;
List<FormField> iter = response.getFields();
for (FormField field : iter) {
String fieldName = field.getVariable();
if ("from".equals(fieldName)) {
smsFrom = field.getValues().get(0);
}
else if ("challenge".equals(fieldName)) {
challenge = field.getValues().get(0);
}
else if ("brand-image".equals(fieldName)) {
brandImage = field.getValues().get(0);
}
else if ("brand-link".equals(fieldName)) {
brandLink = field.getValues().get(0);
}
}
if (smsFrom != null) {
Log.d(TAG, "using sender id: " + smsFrom + ", challenge: " + challenge);
mServerChallenge = challenge;
mListener.onValidationRequested(NumberValidator.this,
smsFrom, challenge, brandImage, brandLink);
// prevent error handling
return;
}
}
}
else if (iq.getType() == IQ.Type.error) {
XMPPError error = iq.getError();
if (error.getCondition() == XMPPError.Condition.conflict) {
reason = ERROR_USER_EXISTS;
}
else if (error.getCondition() == XMPPError.Condition.service_unavailable) {
if (error.getType() == XMPPError.Type.WAIT) {
reason = ERROR_THROTTLING;
}
else {
// no registration support - try the next server
EndpointServer server = mServerProvider.next();
if (server != null) {
// run again with new server
mStep = STEP_INIT;
mConnector.setServer(server);
run();
return;
}
else {
// last server to try, no chance for registration
mListener.onServerCheckFailed(NumberValidator.this);
// onValidationFailed will not be called
reason = -1;
}
}
}
}
// validation failed :(
if (reason >= 0)
mListener.onValidationFailed(NumberValidator.this, reason);
mStep = STEP_INIT;
}
}, new StanzaIdFilter(form.getStanzaId()));
// send registration form
conn.sendStanza(form);
}
// sms received, request authentication token
else if (mStep == STEP_AUTH_TOKEN) {
Log.d(TAG, "requesting authentication token");
// generate keyring immediately
// needed for connection
if (mKey != null) {
String userId = MessageUtils.sha1(mPhone);
mKeyRing = mKey.storeNetwork(userId, mConnector.getNetwork(),
mName, mPassphrase);
}
else {
mKeyRing = PGPKeyPairRing.load(mImportedPrivateKey, mImportedPublicKey);
}
// bridge certificate for connection
mBridgeCert = X509Bridge.createCertificate(mKeyRing.publicKey,
mKeyRing.secretKey.getSecretKey(), mPassphrase);
// connect to server
initConnection();
// prepare final verification form
Stanza form = createValidationForm();
XMPPConnection conn = mConnector.getConnection();
conn.addAsyncStanzaListener(new StanzaListener() {
public void processPacket(Stanza packet) {
IQ iq = (IQ) packet;
if (iq.getType() == IQ.Type.result) {
DataForm response = iq.getExtension("x", "jabber:x:data");
if (response != null) {
String publicKey = null;
// ok! message will be sent
List<FormField> iter = response.getFields();
for (FormField field : iter) {
if ("publickey".equals(field.getVariable())) {
publicKey = field.getValues().get(0);
}
}
if (!TextUtils.isEmpty(publicKey)) {
byte[] publicKeyData;
byte[] privateKeyData;
try {
publicKeyData = Base64.decode(publicKey, Base64.DEFAULT);
privateKeyData = mKeyRing.secretKey.getEncoded();
}
catch (Exception e) {
// TODO that easy?
publicKeyData = null;
privateKeyData = null;
}
mListener.onAuthTokenReceived(NumberValidator.this, privateKeyData, publicKeyData);
// prevent error handling
return;
}
}
}
// validation failed :(
// TODO check for service-unavailable errors (meaning
// we must call onServerCheckFailed()
mListener.onAuthTokenFailed(NumberValidator.this, -1);
mStep = STEP_INIT;
}
}, new StanzaIdFilter(form.getStanzaId()));
// send registration form
conn.sendStanza(form);
}
// try imported key by performing a login test
else if (mStep == STEP_LOGIN_TEST) {
if (mImportedPrivateKey == null || mImportedPublicKey == null)
throw new AssertionError("requesting a login test with no imported key!");
// generate keyring immediately
// needed for connection
mKeyRing = PGPKeyPairRing.load(mImportedPrivateKey, mImportedPublicKey);
// bridge certificate for connection
mBridgeCert = X509Bridge.createCertificate(mKeyRing.publicKey,
mKeyRing.secretKey.getSecretKey(), mPassphrase);
try {
// connect to server
initConnection();
}
catch (Exception e) {
// login test failed, run again normally
mStep = STEP_INIT;
// mark server as dirty
mConnector.setServer(mConnector.getServer());
run();
return;
}
// login successful!!!
if (mListener != null)
mListener.onAuthTokenReceived(this,
mKeyRing.secretKey.getEncoded(), mKeyRing.publicKey.getEncoded());
}
}
catch (Throwable e) {
ReportingManager.logException(e);
if (mListener != null)
mListener.onError(this, e);
mStep = STEP_INIT;
}
}
/**
* Shuts down this thread gracefully.
*/
public synchronized void shutdown() {
Log.w(TAG, "shutting down");
try {
if (mThread != null) {
// save and null everything
final HandlerThread serviceHandler = mServiceHandler;
final Handler internalHandler = mInternalHandler;
mServiceHandler = null;
mInternalHandler = null;
internalHandler.post(new Runnable() {
public void run() {
try {
serviceHandler.quit();
mConnector.shutdown();
}
catch (Exception e) {
// ignored
}
}
});
mThread.interrupt();
mThread = null;
}
unconfigure();
}
catch (Exception e) {
// ignored
}
Log.w(TAG, "exiting");
}
/** Forcibly inputs the validation code. */
public void manualInput(CharSequence code) {
mValidationCode = code;
mStep = STEP_AUTH_TOKEN;
// next start call will trigger the next condition
mThread = null;
}
public void testImport() {
mStep = STEP_LOGIN_TEST;
// next start call will trigger the next condition
mThread = null;
}
private void initConnection() throws XMPPException, SmackException,
PGPException, KeyStoreException, NoSuchProviderException,
NoSuchAlgorithmException, CertificateException,
IOException {
if (!mConnector.isConnected() || mConnector.isServerDirty()) {
mConnector.setListener(this);
PersonalKey key = null;
if (mImportedPrivateKey != null && mImportedPublicKey != null) {
PGPKeyPairRing ring = PGPKeyPairRing.load(mImportedPrivateKey, mImportedPublicKey);
key = PersonalKey.load(ring.secretKey, ring.publicKey, mPassphrase, mBridgeCert);
}
else if (mKey != null) {
key = mKey.copy(mBridgeCert);
}
mConnector.connectOnce(key, mStep == STEP_LOGIN_TEST);
}
}
private Stanza createRegistrationForm() {
Registration iq = new Registration();
iq.setType(IQ.Type.set);
iq.setTo(mConnector.getConnection().getServiceName());
Form form = new Form(DataForm.Type.submit);
FormField type = new FormField("FORM_TYPE");
type.setType(FormField.Type.hidden);
type.addValue(Registration.NAMESPACE);
form.addField(type);
FormField phone = new FormField("phone");
phone.setLabel("Phone number");
phone.setType(FormField.Type.text_single);
phone.addValue(mPhone);
form.addField(phone);
if (mForce) {
FormField force = new FormField("force");
force.setLabel("Force registration");
force.setType(FormField.Type.bool);
force.addValue(String.valueOf(mForce));
form.addField(force);
}
if (mFallback) {
FormField fallback = new FormField("fallback");
fallback.setLabel("Fallback");
fallback.setType(FormField.Type.bool);
fallback.addValue(String.valueOf(mFallback));
form.addField(fallback);
}
else {
// not falling back, ask for our preferred challenge
FormField challenge = new FormField("challenge");
challenge.setLabel("Challenge type");
challenge.setType(FormField.Type.text_single);
challenge.addValue(DEFAULT_CHALLENGE);
form.addField(challenge);
}
iq.addExtension(form.getDataFormToSend());
return iq;
}
private Stanza createValidationForm() throws IOException {
Registration iq = new Registration();
iq.setType(IQ.Type.set);
iq.setTo(mConnector.getConnection().getServiceName());
Form form = new Form(DataForm.Type.submit);
FormField type = new FormField("FORM_TYPE");
type.setType(FormField.Type.hidden);
type.addValue("http://kontalk.org/protocol/register#code");
form.addField(type);
if (mValidationCode != null) {
FormField code = new FormField("code");
code.setLabel("Validation code");
code.setType(FormField.Type.text_single);
code.addValue(mValidationCode.toString());
form.addField(code);
}
iq.addExtension(form.getDataFormToSend());
return iq;
}
public synchronized void setListener(NumberValidatorListener listener) {
mListener = listener;
}
public interface NumberValidatorListener {
/** Called if an exception get thrown. */
void onError(NumberValidator v, Throwable e);
/** Called if the server doesn't support registration/auth tokens. */
void onServerCheckFailed(NumberValidator v);
/** Called on confirmation that the validation SMS is being sent. */
void onValidationRequested(NumberValidator v, String sender, String challenge, String brandImage, String brandLink);
/** Called if phone number validation failed. */
void onValidationFailed(NumberValidator v, int reason);
/** Called on receiving of authentication token. */
void onAuthTokenReceived(NumberValidator v, byte[] privateKey, byte[] publicKey);
/** Called if validation code has not been verified. */
void onAuthTokenFailed(NumberValidator v, int reason);
}
/** Handles special numbers not handled by libphonenumber. */
public static boolean isSpecialNumber(PhoneNumber number) {
if (number.getCountryCode() == 31) {
// handle special M2M numbers: 11 digits starting with 097[0-8]
final Pattern regex = Pattern.compile("^97[0-8][0-9]{8}$");
Matcher m = regex.matcher(String.valueOf(number.getNationalNumber()));
return m.matches();
}
return false;
}
/**
* Converts pretty much any phone number into E.164 format.
* @param myNumber used to take the country code if not found in the number
* @param lastResortCc manual country code last resort
* @throws IllegalArgumentException if no country code is available.
*/
public static String fixNumber(Context context, String number, String myNumber, int lastResortCc)
throws NumberParseException {
final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
String myRegionCode = tm.getSimCountryIso();
if (myRegionCode != null)
myRegionCode = myRegionCode.toUpperCase(Locale.US);
return fixNumber(number, myNumber, myRegionCode, lastResortCc);
}
static String fixNumber(String number, String myNumber, String myRegionCode, int lastResortCc)
throws NumberParseException {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
try {
if (myNumber != null) {
PhoneNumber myNum = util.parse(myNumber, myRegionCode);
// use region code found in my number
myRegionCode = util.getRegionCodeForNumber(myNum);
}
}
catch (NumberParseException e) {
// ehm :)
}
PhoneNumber parsedNum;
try {
parsedNum = util.parse(number, myRegionCode);
}
catch (NumberParseException e) {
// parse failed with default region code, try last resort
if (lastResortCc > 0) {
myRegionCode = util.getRegionCodeForCountryCode(lastResortCc);
parsedNum = util.parse(number, myRegionCode);
}
else
throw e;
}
handleSpecialCases(parsedNum);
// a NumberParseException would have been thrown at this point
return util.format(parsedNum, PhoneNumberFormat.E164);
}
/**
* Handles special cases in a parsed phone number.
* @param phoneNumber the phone number to check. It will be modified in place.
*/
public static void handleSpecialCases(PhoneNumber phoneNumber) {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
// Argentina numbering rules
int argCode = util.getCountryCodeForRegion("AR");
if (phoneNumber.getCountryCode() == argCode) {
// forcibly add the 9 between country code and national number
long nsn = phoneNumber.getNationalNumber();
if (firstDigit(nsn) != 9) {
phoneNumber.setNationalNumber(addSignificantDigits(nsn, 9));
}
}
}
static long addSignificantDigits(long n, int ds) {
final long orig = n;
int count = 1;
while (n < -9 || 9 < n) {
n /= 10;
count++;
}
long power = ds * (long) Math.pow(10, count);
return orig + power;
}
private static int firstDigit(long n) {
while (n < -9 || 9 < n) n /= 10;
return (int) Math.abs(n);
}
/** Returns the (parsed) number stored in this device SIM card. */
@SuppressLint("HardwareIds")
public static PhoneNumber getMyNumber(Context context) {
try {
final TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
final String regionCode = tm.getSimCountryIso().toUpperCase(Locale.US);
return PhoneNumberUtil.getInstance().parse(tm.getLine1Number(), regionCode);
}
catch (Exception e) {
return null;
}
}
/** Returns the localized region name for the given region code. */
public static String getRegionDisplayName(String regionCode, Locale language) {
return (regionCode == null || regionCode.equals("ZZ") ||
regionCode.equals(PhoneNumberUtil.REGION_CODE_FOR_NON_GEO_ENTITY))
? "" : new Locale("", regionCode).getDisplayCountry(language);
}
@Override
public void connectionClosed() {
// not used
}
@Override
public void connectionClosedOnError(Exception e) {
// not used
}
@Override
public void reconnectingIn(int seconds) {
// not used
}
@Override
public void reconnectionFailed(Exception e) {
// not used
}
@Override
public void reconnectionSuccessful() {
// not used
}
@Override
public void aborted(Exception e) {
// TODO Auto-generated method stub
}
@Override
public void created(XMPPConnection conn) {
// not used
}
@Override
public void connected(XMPPConnection conn) {
// not used
}
@Override
public void authenticated(XMPPConnection conn, boolean resumed) {
// not used
}
@Override
public void authenticationFailed() {
// not used
}
}