aboutsummaryrefslogtreecommitdiffstats
path: root/src/main/java/de/thedevstack/conversationsplus/crypto
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/de/thedevstack/conversationsplus/crypto')
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/OtrEngine.java303
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/PgpEngine.java369
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/sasl/DigestMd5.java91
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Plain.java30
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/sasl/SaslMechanism.java62
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/sasl/ScramSha1.java232
-rw-r--r--src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Tokenizer.java78
7 files changed, 1165 insertions, 0 deletions
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/OtrEngine.java b/src/main/java/de/thedevstack/conversationsplus/crypto/OtrEngine.java
new file mode 100644
index 00000000..dfb64eaf
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/OtrEngine.java
@@ -0,0 +1,303 @@
+package de.thedevstack.conversationsplus.crypto;
+
+import java.math.BigInteger;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.spec.DSAPrivateKeySpec;
+import java.security.spec.DSAPublicKeySpec;
+import java.security.spec.InvalidKeySpecException;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.util.Log;
+
+import de.thedevstack.conversationsplus.Config;
+import de.thedevstack.conversationsplus.entities.Account;
+import de.thedevstack.conversationsplus.entities.Conversation;
+import de.thedevstack.conversationsplus.services.XmppConnectionService;
+import de.thedevstack.conversationsplus.xmpp.chatstate.ChatState;
+import de.thedevstack.conversationsplus.xmpp.jid.InvalidJidException;
+import de.thedevstack.conversationsplus.xmpp.jid.Jid;
+import de.thedevstack.conversationsplus.xmpp.stanzas.MessagePacket;
+
+import net.java.otr4j.OtrEngineHost;
+import net.java.otr4j.OtrException;
+import net.java.otr4j.OtrPolicy;
+import net.java.otr4j.OtrPolicyImpl;
+import net.java.otr4j.crypto.OtrCryptoEngineImpl;
+import net.java.otr4j.crypto.OtrCryptoException;
+import net.java.otr4j.session.InstanceTag;
+import net.java.otr4j.session.SessionID;
+import net.java.otr4j.session.FragmenterInstructions;
+
+public class OtrEngine extends OtrCryptoEngineImpl implements OtrEngineHost {
+
+ private Account account;
+ private OtrPolicy otrPolicy;
+ private KeyPair keyPair;
+ private XmppConnectionService mXmppConnectionService;
+
+ public OtrEngine(XmppConnectionService service, Account account) {
+ this.account = account;
+ this.otrPolicy = new OtrPolicyImpl();
+ this.otrPolicy.setAllowV1(false);
+ this.otrPolicy.setAllowV2(true);
+ this.otrPolicy.setAllowV3(true);
+ this.keyPair = loadKey(account.getKeys());
+ this.mXmppConnectionService = service;
+ }
+
+ private KeyPair loadKey(JSONObject keys) {
+ if (keys == null) {
+ return null;
+ }
+ try {
+ BigInteger x = new BigInteger(keys.getString("otr_x"), 16);
+ BigInteger y = new BigInteger(keys.getString("otr_y"), 16);
+ BigInteger p = new BigInteger(keys.getString("otr_p"), 16);
+ BigInteger q = new BigInteger(keys.getString("otr_q"), 16);
+ BigInteger g = new BigInteger(keys.getString("otr_g"), 16);
+ KeyFactory keyFactory = KeyFactory.getInstance("DSA");
+ DSAPublicKeySpec pubKeySpec = new DSAPublicKeySpec(y, p, q, g);
+ DSAPrivateKeySpec privateKeySpec = new DSAPrivateKeySpec(x, p, q, g);
+ PublicKey publicKey = keyFactory.generatePublic(pubKeySpec);
+ PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
+ return new KeyPair(publicKey, privateKey);
+ } catch (JSONException e) {
+ return null;
+ } catch (NoSuchAlgorithmException e) {
+ return null;
+ } catch (InvalidKeySpecException e) {
+ return null;
+ }
+ }
+
+ private void saveKey() {
+ PublicKey publicKey = keyPair.getPublic();
+ PrivateKey privateKey = keyPair.getPrivate();
+ KeyFactory keyFactory;
+ try {
+ keyFactory = KeyFactory.getInstance("DSA");
+ DSAPrivateKeySpec privateKeySpec = keyFactory.getKeySpec(
+ privateKey, DSAPrivateKeySpec.class);
+ DSAPublicKeySpec publicKeySpec = keyFactory.getKeySpec(publicKey,
+ DSAPublicKeySpec.class);
+ this.account.setKey("otr_x", privateKeySpec.getX().toString(16));
+ this.account.setKey("otr_g", privateKeySpec.getG().toString(16));
+ this.account.setKey("otr_p", privateKeySpec.getP().toString(16));
+ this.account.setKey("otr_q", privateKeySpec.getQ().toString(16));
+ this.account.setKey("otr_y", publicKeySpec.getY().toString(16));
+ } catch (final NoSuchAlgorithmException | InvalidKeySpecException e) {
+ e.printStackTrace();
+ }
+
+ }
+
+ @Override
+ public void askForSecret(SessionID id, InstanceTag instanceTag, String question) {
+ try {
+ final Jid jid = Jid.fromSessionID(id);
+ Conversation conversation = this.mXmppConnectionService.find(this.account,jid);
+ if (conversation!=null) {
+ conversation.smp().hint = question;
+ conversation.smp().status = Conversation.Smp.STATUS_CONTACT_REQUESTED;
+ mXmppConnectionService.updateConversationUi();
+ }
+ } catch (InvalidJidException e) {
+ Log.d(Config.LOGTAG,account.getJid().toBareJid()+": smp in invalid session "+id.toString());
+ }
+ }
+
+ @Override
+ public void finishedSessionMessage(SessionID arg0, String arg1)
+ throws OtrException {
+
+ }
+
+ @Override
+ public String getFallbackMessage(SessionID arg0) {
+ return "I would like to start a private (OTR encrypted) conversation but your client doesn’t seem to support that";
+ }
+
+ @Override
+ public byte[] getLocalFingerprintRaw(SessionID arg0) {
+ try {
+ return getFingerprintRaw(getPublicKey());
+ } catch (OtrCryptoException e) {
+ return null;
+ }
+ }
+
+ public PublicKey getPublicKey() {
+ if (this.keyPair == null) {
+ return null;
+ }
+ return this.keyPair.getPublic();
+ }
+
+ @Override
+ public KeyPair getLocalKeyPair(SessionID arg0) throws OtrException {
+ if (this.keyPair == null) {
+ KeyPairGenerator kg;
+ try {
+ kg = KeyPairGenerator.getInstance("DSA");
+ this.keyPair = kg.genKeyPair();
+ this.saveKey();
+ mXmppConnectionService.databaseBackend.updateAccount(account);
+ } catch (NoSuchAlgorithmException e) {
+ Log.d(Config.LOGTAG,
+ "error generating key pair " + e.getMessage());
+ }
+ }
+ return this.keyPair;
+ }
+
+ @Override
+ public String getReplyForUnreadableMessage(SessionID arg0) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+ @Override
+ public OtrPolicy getSessionPolicy(SessionID arg0) {
+ return otrPolicy;
+ }
+
+ @Override
+ public void injectMessage(SessionID session, String body)
+ throws OtrException {
+ MessagePacket packet = new MessagePacket();
+ packet.setFrom(account.getJid());
+ if (session.getUserID().isEmpty()) {
+ packet.setAttribute("to", session.getAccountID());
+ } else {
+ packet.setAttribute("to", session.getAccountID() + "/" + session.getUserID());
+ }
+ packet.setBody(body);
+ packet.addChild("private", "urn:xmpp:carbons:2");
+ packet.addChild("no-copy", "urn:xmpp:hints");
+ packet.addChild("no-store", "urn:xmpp:hints");
+
+ try {
+ Jid jid = Jid.fromSessionID(session);
+ Conversation conversation = mXmppConnectionService.find(account,jid);
+ if (conversation != null && conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) {
+ if (mXmppConnectionService.sendChatStates()) {
+ packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
+ }
+ }
+ } catch (final InvalidJidException ignored) {
+
+ }
+
+ packet.setType(MessagePacket.TYPE_CHAT);
+ account.getXmppConnection().sendMessagePacket(packet);
+ }
+
+ @Override
+ public void messageFromAnotherInstanceReceived(SessionID session) {
+ try {
+ Jid jid = Jid.fromSessionID(session);
+ Conversation conversation = mXmppConnectionService.find(account, jid);
+ String id = conversation == null ? null : conversation.getLastReceivedOtrMessageId();
+ if (id != null) {
+ MessagePacket packet = mXmppConnectionService.getMessageGenerator().generateOtrError(jid,id);
+ packet.setFrom(account.getJid());
+ mXmppConnectionService.sendMessagePacket(account,packet);
+ Log.d(Config.LOGTAG,packet.toString());
+ Log.d(Config.LOGTAG,account.getJid().toBareJid().toString()+": unreadable OTR message in "+conversation.getName());
+ }
+ } catch (InvalidJidException e) {
+ return;
+ }
+ }
+
+ @Override
+ public void multipleInstancesDetected(SessionID arg0) {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void requireEncryptedMessage(SessionID arg0, String arg1)
+ throws OtrException {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void showError(SessionID arg0, String arg1) throws OtrException {
+ Log.d(Config.LOGTAG,"show error");
+ }
+
+ @Override
+ public void smpAborted(SessionID id) throws OtrException {
+ setSmpStatus(id, Conversation.Smp.STATUS_NONE);
+ }
+
+ private void setSmpStatus(SessionID id, int status) {
+ try {
+ final Jid jid = Jid.fromSessionID(id);
+ Conversation conversation = this.mXmppConnectionService.find(this.account,jid);
+ if (conversation!=null) {
+ conversation.smp().status = status;
+ mXmppConnectionService.updateConversationUi();
+ }
+ } catch (final InvalidJidException ignored) {
+
+ }
+ }
+
+ @Override
+ public void smpError(SessionID id, int arg1, boolean arg2)
+ throws OtrException {
+ setSmpStatus(id, Conversation.Smp.STATUS_NONE);
+ }
+
+ @Override
+ public void unencryptedMessageReceived(SessionID arg0, String arg1)
+ throws OtrException {
+ throw new OtrException(new Exception("unencrypted message received"));
+ }
+
+ @Override
+ public void unreadableMessageReceived(SessionID arg0) throws OtrException {
+ Log.d(Config.LOGTAG,"unreadable message received");
+ throw new OtrException(new Exception("unreadable message received"));
+ }
+
+ @Override
+ public void unverify(SessionID id, String arg1) {
+ setSmpStatus(id, Conversation.Smp.STATUS_FAILED);
+ }
+
+ @Override
+ public void verify(SessionID id, String fingerprint, boolean approved) {
+ Log.d(Config.LOGTAG,"OtrEngine.verify("+id.toString()+","+fingerprint+","+String.valueOf(approved)+")");
+ try {
+ final Jid jid = Jid.fromSessionID(id);
+ Conversation conversation = this.mXmppConnectionService.find(this.account,jid);
+ if (conversation!=null) {
+ if (approved) {
+ conversation.getContact().addOtrFingerprint(fingerprint);
+ }
+ conversation.smp().hint = null;
+ conversation.smp().status = Conversation.Smp.STATUS_VERIFIED;
+ mXmppConnectionService.updateConversationUi();
+ mXmppConnectionService.syncRosterToDisk(conversation.getAccount());
+ }
+ } catch (final InvalidJidException ignored) {
+ }
+ }
+
+ @Override
+ public FragmenterInstructions getFragmenterInstructions(SessionID sessionID) {
+ return null;
+ }
+
+}
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/PgpEngine.java b/src/main/java/de/thedevstack/conversationsplus/crypto/PgpEngine.java
new file mode 100644
index 00000000..b7fa9585
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/PgpEngine.java
@@ -0,0 +1,369 @@
+package de.thedevstack.conversationsplus.crypto;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+
+import org.openintents.openpgp.OpenPgpSignatureResult;
+import org.openintents.openpgp.util.OpenPgpApi;
+import org.openintents.openpgp.util.OpenPgpApi.IOpenPgpCallback;
+
+import de.tzur.conversations.Settings;
+import de.thedevstack.conversationsplus.R;
+import de.thedevstack.conversationsplus.entities.Account;
+import de.thedevstack.conversationsplus.entities.Contact;
+import de.thedevstack.conversationsplus.entities.Conversation;
+import de.thedevstack.conversationsplus.entities.DownloadableFile;
+import de.thedevstack.conversationsplus.entities.Message;
+import de.thedevstack.conversationsplus.http.HttpConnectionManager;
+import de.thedevstack.conversationsplus.services.XmppConnectionService;
+import de.thedevstack.conversationsplus.ui.UiCallback;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.net.Uri;
+
+public class PgpEngine {
+ private OpenPgpApi api;
+ private XmppConnectionService mXmppConnectionService;
+
+ public PgpEngine(OpenPgpApi api, XmppConnectionService service) {
+ this.api = api;
+ this.mXmppConnectionService = service;
+ }
+
+ public void decrypt(final Message message,
+ final UiCallback<Message> callback) {
+ Intent params = new Intent();
+ params.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY);
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, message
+ .getConversation().getAccount().getJid().toBareJid().toString());
+ if (message.getType() == Message.TYPE_TEXT) {
+ InputStream is = new ByteArrayInputStream(message.getBody()
+ .getBytes());
+ final OutputStream os = new ByteArrayOutputStream();
+ api.executeApiAsync(params, is, os, new IOpenPgpCallback() {
+
+ @Override
+ public void onReturn(Intent result) {
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE,
+ OpenPgpApi.RESULT_CODE_ERROR)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ try {
+ os.flush();
+ if (message.getEncryption() == Message.ENCRYPTION_PGP) {
+ message.setBody(os.toString());
+ message.setEncryption(Message.ENCRYPTION_DECRYPTED);
+ final HttpConnectionManager manager = mXmppConnectionService.getHttpConnectionManager();
+ if (message.trusted()
+ && Settings.DOWNLOAD_IMAGE_LINKS
+ && mXmppConnectionService.isDownloadAllowedInConnection()
+ && message.bodyContainsDownloadable()
+ && manager.getAutoAcceptFileSize() > 0) {
+ manager.createNewConnection(message);
+ }
+ callback.success(message);
+ }
+ } catch (IOException e) {
+ callback.error(R.string.openpgp_error, message);
+ return;
+ }
+
+ return;
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ callback.userInputRequried((PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT),
+ message);
+ return;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ callback.error(R.string.openpgp_error, message);
+ }
+ }
+ });
+ } else if (message.getType() == Message.TYPE_IMAGE || message.getType() == Message.TYPE_FILE) {
+ try {
+ final DownloadableFile inputFile = this.mXmppConnectionService
+ .getFileBackend().getFile(message, false);
+ final DownloadableFile outputFile = this.mXmppConnectionService
+ .getFileBackend().getFile(message, true);
+ outputFile.getParentFile().mkdirs();
+ outputFile.createNewFile();
+ InputStream is = new FileInputStream(inputFile);
+ OutputStream os = new FileOutputStream(outputFile);
+ api.executeApiAsync(params, is, os, new IOpenPgpCallback() {
+
+ @Override
+ public void onReturn(Intent result) {
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE,
+ OpenPgpApi.RESULT_CODE_ERROR)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ URL url = message.getImageParams().url;
+ mXmppConnectionService.getFileBackend().updateFileParams(message,url);
+ message.setEncryption(Message.ENCRYPTION_DECRYPTED);
+ PgpEngine.this.mXmppConnectionService
+ .updateMessage(message);
+ inputFile.delete();
+ Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
+ intent.setData(Uri.fromFile(outputFile));
+ mXmppConnectionService.sendBroadcast(intent);
+ callback.success(message);
+ return;
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ callback.userInputRequried(
+ (PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT),
+ message);
+ return;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ callback.error(R.string.openpgp_error, message);
+ }
+ }
+ });
+ } catch (final IOException e) {
+ callback.error(R.string.error_decrypting_file, message);
+ }
+
+ }
+ }
+
+ public void encrypt(final Message message,
+ final UiCallback<Message> callback) {
+
+ Intent params = new Intent();
+ params.setAction(OpenPgpApi.ACTION_ENCRYPT);
+ if (message.getConversation().getMode() == Conversation.MODE_SINGLE) {
+ long[] keys = { message.getConversation().getContact()
+ .getPgpKeyId() };
+ params.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keys);
+ } else {
+ params.putExtra(OpenPgpApi.EXTRA_KEY_IDS, message.getConversation()
+ .getMucOptions().getPgpKeyIds());
+ }
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, message
+ .getConversation().getAccount().getJid().toBareJid().toString());
+
+ if (message.getType() == Message.TYPE_TEXT) {
+ params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true);
+
+ InputStream is = new ByteArrayInputStream(message.getBody()
+ .getBytes());
+ final OutputStream os = new ByteArrayOutputStream();
+ api.executeApiAsync(params, is, os, new IOpenPgpCallback() {
+
+ @Override
+ public void onReturn(Intent result) {
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE,
+ OpenPgpApi.RESULT_CODE_ERROR)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ try {
+ os.flush();
+ StringBuilder encryptedMessageBody = new StringBuilder();
+ String[] lines = os.toString().split("\n");
+ for (int i = 2; i < lines.length - 1; ++i) {
+ if (!lines[i].contains("Version")) {
+ encryptedMessageBody.append(lines[i]);
+ }
+ }
+ message.setEncryptedBody(encryptedMessageBody
+ .toString());
+ callback.success(message);
+ } catch (IOException e) {
+ callback.error(R.string.openpgp_error, message);
+ }
+
+ break;
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ callback.userInputRequried((PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT),
+ message);
+ break;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ callback.error(R.string.openpgp_error, message);
+ break;
+ }
+ }
+ });
+ } else if (message.getType() == Message.TYPE_IMAGE || message.getType() == Message.TYPE_FILE) {
+ try {
+ DownloadableFile inputFile = this.mXmppConnectionService
+ .getFileBackend().getFile(message, true);
+ DownloadableFile outputFile = this.mXmppConnectionService
+ .getFileBackend().getFile(message, false);
+ outputFile.getParentFile().mkdirs();
+ outputFile.createNewFile();
+ InputStream is = new FileInputStream(inputFile);
+ OutputStream os = new FileOutputStream(outputFile);
+ api.executeApiAsync(params, is, os, new IOpenPgpCallback() {
+
+ @Override
+ public void onReturn(Intent result) {
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE,
+ OpenPgpApi.RESULT_CODE_ERROR)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ callback.success(message);
+ break;
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ callback.userInputRequried(
+ (PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT),
+ message);
+ break;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ callback.error(R.string.openpgp_error, message);
+ break;
+ }
+ }
+ });
+ } catch (final IOException e) {
+ callback.error(R.string.openpgp_error, message);
+ }
+ }
+ }
+
+ public long fetchKeyId(Account account, String status, String signature) {
+ if ((signature == null) || (api == null)) {
+ return 0;
+ }
+ if (status == null) {
+ status = "";
+ }
+ final StringBuilder pgpSig = new StringBuilder();
+ pgpSig.append("-----BEGIN PGP SIGNED MESSAGE-----");
+ pgpSig.append('\n');
+ pgpSig.append('\n');
+ pgpSig.append(status);
+ pgpSig.append('\n');
+ pgpSig.append("-----BEGIN PGP SIGNATURE-----");
+ pgpSig.append('\n');
+ pgpSig.append('\n');
+ pgpSig.append(signature.replace("\n", "").trim());
+ pgpSig.append('\n');
+ pgpSig.append("-----END PGP SIGNATURE-----");
+ Intent params = new Intent();
+ params.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY);
+ params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true);
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, account.getJid().toBareJid().toString());
+ InputStream is = new ByteArrayInputStream(pgpSig.toString().getBytes());
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ Intent result = api.executeApi(params, is, os);
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE,
+ OpenPgpApi.RESULT_CODE_ERROR)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ OpenPgpSignatureResult sigResult = result
+ .getParcelableExtra(OpenPgpApi.RESULT_SIGNATURE);
+ if (sigResult != null) {
+ return sigResult.getKeyId();
+ } else {
+ return 0;
+ }
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ return 0;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ return 0;
+ }
+ return 0;
+ }
+
+ public void generateSignature(final Account account, String status,
+ final UiCallback<Account> callback) {
+ Intent params = new Intent();
+ params.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true);
+ params.setAction(OpenPgpApi.ACTION_SIGN);
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, account.getJid().toBareJid().toString());
+ InputStream is = new ByteArrayInputStream(status.getBytes());
+ final OutputStream os = new ByteArrayOutputStream();
+ api.executeApiAsync(params, is, os, new IOpenPgpCallback() {
+
+ @Override
+ public void onReturn(Intent result) {
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, 0)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ StringBuilder signatureBuilder = new StringBuilder();
+ try {
+ os.flush();
+ String[] lines = os.toString().split("\n");
+ boolean sig = false;
+ for (String line : lines) {
+ if (sig) {
+ if (line.contains("END PGP SIGNATURE")) {
+ sig = false;
+ } else {
+ if (!line.contains("Version")) {
+ signatureBuilder.append(line.trim());
+ }
+ }
+ }
+ if (line.contains("BEGIN PGP SIGNATURE")) {
+ sig = true;
+ }
+ }
+ } catch (IOException e) {
+ callback.error(R.string.openpgp_error, account);
+ return;
+ }
+ account.setKey("pgp_signature", signatureBuilder.toString());
+ callback.success(account);
+ return;
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ callback.userInputRequried((PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT),
+ account);
+ return;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ callback.error(R.string.openpgp_error, account);
+ }
+ }
+ });
+ }
+
+ public void hasKey(final Contact contact, final UiCallback<Contact> callback) {
+ Intent params = new Intent();
+ params.setAction(OpenPgpApi.ACTION_GET_KEY);
+ params.putExtra(OpenPgpApi.EXTRA_KEY_ID, contact.getPgpKeyId());
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, contact.getAccount()
+ .getJid().toBareJid().toString());
+ api.executeApiAsync(params, null, null, new IOpenPgpCallback() {
+
+ @Override
+ public void onReturn(Intent result) {
+ switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, 0)) {
+ case OpenPgpApi.RESULT_CODE_SUCCESS:
+ callback.success(contact);
+ return;
+ case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
+ callback.userInputRequried((PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT),
+ contact);
+ return;
+ case OpenPgpApi.RESULT_CODE_ERROR:
+ callback.error(R.string.openpgp_error, contact);
+ }
+ }
+ });
+ }
+
+ public PendingIntent getIntentForKey(Contact contact) {
+ Intent params = new Intent();
+ params.setAction(OpenPgpApi.ACTION_GET_KEY);
+ params.putExtra(OpenPgpApi.EXTRA_KEY_ID, contact.getPgpKeyId());
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, contact.getAccount()
+ .getJid().toBareJid().toString());
+ Intent result = api.executeApi(params, null, null);
+ return (PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT);
+ }
+
+ public PendingIntent getIntentForKey(Account account, long pgpKeyId) {
+ Intent params = new Intent();
+ params.setAction(OpenPgpApi.ACTION_GET_KEY);
+ params.putExtra(OpenPgpApi.EXTRA_KEY_ID, pgpKeyId);
+ params.putExtra(OpenPgpApi.EXTRA_ACCOUNT_NAME, account.getJid().toBareJid().toString());
+ Intent result = api.executeApi(params, null, null);
+ return (PendingIntent) result
+ .getParcelableExtra(OpenPgpApi.RESULT_INTENT);
+ }
+}
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/DigestMd5.java b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/DigestMd5.java
new file mode 100644
index 00000000..f4c3f28e
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/DigestMd5.java
@@ -0,0 +1,91 @@
+package de.thedevstack.conversationsplus.crypto.sasl;
+
+import android.util.Base64;
+
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+
+import de.thedevstack.conversationsplus.entities.Account;
+import de.thedevstack.conversationsplus.utils.CryptoHelper;
+import de.thedevstack.conversationsplus.xml.TagWriter;
+
+public class DigestMd5 extends SaslMechanism {
+ public DigestMd5(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
+ super(tagWriter, account, rng);
+ }
+
+ @Override
+ public int getPriority() {
+ return 10;
+ }
+
+ @Override
+ public String getMechanism() {
+ return "DIGEST-MD5";
+ }
+
+ private State state = State.INITIAL;
+
+ @Override
+ public String getResponse(final String challenge) 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.getServer();
+ final String nonceCount = "00000001";
+ final String x = account.getUsername() + ":" + account.getServer() + ":"
+ + account.getPassword();
+ final MessageDigest md = MessageDigest.getInstance("MD5");
+ final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
+ final String cNonce = new BigInteger(100, rng).toString(32);
+ 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.getUsername()
+ + "\",realm=\"" + account.getServer() + "\",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;
+ }
+}
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Plain.java b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Plain.java
new file mode 100644
index 00000000..179c12de
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Plain.java
@@ -0,0 +1,30 @@
+package de.thedevstack.conversationsplus.crypto.sasl;
+
+import android.util.Base64;
+
+import java.nio.charset.Charset;
+
+import de.thedevstack.conversationsplus.entities.Account;
+import de.thedevstack.conversationsplus.xml.TagWriter;
+
+public class Plain extends SaslMechanism {
+ public Plain(final TagWriter tagWriter, final Account account) {
+ super(tagWriter, account, null);
+ }
+
+ @Override
+ public int getPriority() {
+ return 10;
+ }
+
+ @Override
+ public String getMechanism() {
+ return "PLAIN";
+ }
+
+ @Override
+ public String getClientFirstMessage() {
+ final String sasl = '\u0000' + account.getUsername() + '\u0000' + account.getPassword();
+ return Base64.encodeToString(sasl.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
+ }
+}
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/SaslMechanism.java b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/SaslMechanism.java
new file mode 100644
index 00000000..ed2764c8
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/SaslMechanism.java
@@ -0,0 +1,62 @@
+package de.thedevstack.conversationsplus.crypto.sasl;
+
+import java.security.SecureRandom;
+
+import de.thedevstack.conversationsplus.entities.Account;
+import de.thedevstack.conversationsplus.xml.TagWriter;
+
+public abstract class SaslMechanism {
+
+ final protected TagWriter tagWriter;
+ final protected Account account;
+ final protected SecureRandom rng;
+
+ protected static enum State {
+ INITIAL,
+ AUTH_TEXT_SENT,
+ RESPONSE_SENT,
+ VALID_SERVER_RESPONSE,
+ }
+
+ public static class AuthenticationException extends Exception {
+ public AuthenticationException(final String message) {
+ super(message);
+ }
+
+ public AuthenticationException(final Exception inner) {
+ super(inner);
+ }
+ }
+
+ public static class InvalidStateException extends AuthenticationException {
+ public InvalidStateException(final String message) {
+ super(message);
+ }
+
+ public InvalidStateException(final State state) {
+ this("Invalid state: " + state.toString());
+ }
+ }
+
+ public SaslMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
+ this.tagWriter = tagWriter;
+ this.account = account;
+ this.rng = rng;
+ }
+
+ /**
+ * 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() {
+ return "";
+ }
+ public String getResponse(final String challenge) throws AuthenticationException {
+ return "";
+ }
+}
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/ScramSha1.java b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/ScramSha1.java
new file mode 100644
index 00000000..af77771c
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/ScramSha1.java
@@ -0,0 +1,232 @@
+package de.thedevstack.conversationsplus.crypto.sasl;
+
+import android.util.Base64;
+import android.util.LruCache;
+
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+import org.bouncycastle.crypto.macs.HMac;
+import org.bouncycastle.crypto.params.KeyParameter;
+
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.security.InvalidKeyException;
+import java.security.SecureRandom;
+
+import de.thedevstack.conversationsplus.entities.Account;
+import de.thedevstack.conversationsplus.utils.CryptoHelper;
+import de.thedevstack.conversationsplus.xml.TagWriter;
+
+public class ScramSha1 extends SaslMechanism {
+ // TODO: When channel binding (SCRAM-SHA1-PLUS) is supported in future, generalize this to indicate support and/or usage.
+ final private static String GS2_HEADER = "n,,";
+ private String clientFirstMessageBare;
+ private byte[] serverFirstMessage;
+ final private String clientNonce;
+ private byte[] serverSignature = null;
+ private static HMac HMAC;
+ private static Digest DIGEST;
+ private static final byte[] CLIENT_KEY_BYTES = "Client Key".getBytes();
+ private static final byte[] SERVER_KEY_BYTES = "Server Key".getBytes();
+
+ public static class KeyPair {
+ final public byte[] clientKey;
+ final public byte[] serverKey;
+
+ public KeyPair(final byte[] clientKey, final byte[] serverKey) {
+ this.clientKey = clientKey;
+ this.serverKey = serverKey;
+ }
+ }
+
+ private static final LruCache<String, KeyPair> CACHE;
+
+ static {
+ DIGEST = new SHA1Digest();
+ HMAC = new HMac(new SHA1Digest());
+ CACHE = new LruCache<String, KeyPair>(10) {
+ protected KeyPair create(final String k) {
+ // Map keys are "bytesToHex(JID),bytesToHex(password),bytesToHex(salt),iterations".
+ // Changing any of these values forces a cache miss. `CryptoHelper.bytesToHex()'
+ // is applied to prevent commas in the strings breaking things.
+ final String[] kparts = k.split(",", 4);
+ try {
+ final byte[] saltedPassword, serverKey, clientKey;
+ saltedPassword = hi(CryptoHelper.hexToString(kparts[1]).getBytes(),
+ Base64.decode(CryptoHelper.hexToString(kparts[2]), Base64.DEFAULT), Integer.valueOf(kparts[3]));
+ serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
+ clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
+
+ return new KeyPair(clientKey, serverKey);
+ } catch (final InvalidKeyException | NumberFormatException e) {
+ return null;
+ }
+ }
+ };
+ }
+
+ private State state = State.INITIAL;
+
+ public ScramSha1(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
+ super(tagWriter, account, rng);
+
+ // This nonce should be different for each authentication attempt.
+ clientNonce = new BigInteger(100, this.rng).toString(32);
+ clientFirstMessageBare = "";
+ }
+
+ @Override
+ public int getPriority() {
+ return 20;
+ }
+
+ @Override
+ public String getMechanism() {
+ return "SCRAM-SHA-1";
+ }
+
+ @Override
+ public String getClientFirstMessage() {
+ if (clientFirstMessageBare.isEmpty() && state == State.INITIAL) {
+ clientFirstMessageBare = "n=" + CryptoHelper.saslEscape(CryptoHelper.saslPrep(account.getUsername())) +
+ ",r=" + this.clientNonce;
+ state = State.AUTH_TEXT_SENT;
+ }
+ return Base64.encodeToString(
+ (GS2_HEADER + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
+ Base64.NO_WRAP);
+ }
+
+ @Override
+ public String getResponse(final String challenge) throws AuthenticationException {
+ switch (state) {
+ case AUTH_TEXT_SENT:
+ serverFirstMessage = Base64.decode(challenge, Base64.DEFAULT);
+ 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 String clientFinalMessageWithoutProof = "c=" + Base64.encodeToString(
+ GS2_HEADER.getBytes(), Base64.NO_WRAP) + ",r=" + nonce;
+ final byte[] authMessage = (clientFirstMessageBare + ',' + new String(serverFirstMessage) + ','
+ + clientFinalMessageWithoutProof).getBytes();
+
+ // Map keys are "bytesToHex(JID),bytesToHex(password),bytesToHex(salt),iterations".
+ final KeyPair keys = CACHE.get(
+ CryptoHelper.bytesToHex(account.getJid().toBareJid().toString().getBytes()) + ","
+ + CryptoHelper.bytesToHex(account.getPassword().getBytes()) + ","
+ + CryptoHelper.bytesToHex(salt.getBytes()) + ","
+ + String.valueOf(iterationCount)
+ );
+ if (keys == null) {
+ 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];
+
+ 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:
+ final String clientCalculatedServerFinalMessage = "v=" +
+ Base64.encodeToString(serverSignature, Base64.NO_WRAP);
+ if (!clientCalculatedServerFinalMessage.equals(new String(Base64.decode(challenge, Base64.DEFAULT)))) {
+ throw new AuthenticationException("Server final message does not match calculated final message");
+ }
+ state = State.VALID_SERVER_RESPONSE;
+ return "";
+ default:
+ throw new InvalidStateException(state);
+ }
+ }
+
+ public static synchronized byte[] hmac(final byte[] key, final byte[] input)
+ throws InvalidKeyException {
+ HMAC.init(new KeyParameter(key));
+ HMAC.update(input, 0, input.length);
+ final byte[] out = new byte[HMAC.getMacSize()];
+ HMAC.doFinal(out, 0);
+ return out;
+ }
+
+ public static synchronized byte[] digest(byte[] bytes) {
+ DIGEST.reset();
+ DIGEST.update(bytes, 0, bytes.length);
+ final byte[] out = new byte[DIGEST.getDigestSize()];
+ DIGEST.doFinal(out, 0);
+ return out;
+ }
+
+ /*
+ * Hi() is, essentially, PBKDF2 [RFC2898] with HMAC() as the
+ * pseudorandom function (PRF) and with dkLen == output length of
+ * HMAC() == output length of H().
+ */
+ private static synchronized 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;
+ }
+}
diff --git a/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Tokenizer.java b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Tokenizer.java
new file mode 100644
index 00000000..cc2805de
--- /dev/null
+++ b/src/main/java/de/thedevstack/conversationsplus/crypto/sasl/Tokenizer.java
@@ -0,0 +1,78 @@
+package de.thedevstack.conversationsplus.crypto.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();
+ }
+}