diff options
author | steckbrief <steckbrief@chefmail.de> | 2015-05-03 22:25:46 +0200 |
---|---|---|
committer | lookshe <github@lookshe.org> | 2015-06-19 09:46:40 +0200 |
commit | 7382e3af9769f76fe4e19934a59e45a3f9858332 (patch) | |
tree | c37cdb03dfaeaccde7c8dd7c79887bf0de278f83 /src/main/java/de/thedevstack/conversationsplus/crypto | |
parent | b3b4a2902e37fb072e800f5dff0392755f5d4501 (diff) |
renaming eu.siacs.conversations to de.thedevstack.conversationsplus
"renaming eu.siacs.conversations to de.thedevstack.conversationsplus"
package renaming completed
Diffstat (limited to 'src/main/java/de/thedevstack/conversationsplus/crypto')
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(); + } +} |