diff options
Diffstat (limited to 'OpenKeychain/src/main/java')
8 files changed, 810 insertions, 28 deletions
| diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/operations/results/OperationResult.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/operations/results/OperationResult.java index dc45fabc3..70d999242 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/operations/results/OperationResult.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/operations/results/OperationResult.java @@ -545,6 +545,15 @@ public abstract class OperationResult implements Parcelable {          MSG_DC_TRAIL_UNKNOWN (LogLevel.DEBUG, R.string.msg_dc_trail_unknown),          MSG_DC_UNLOCKING (LogLevel.INFO, R.string.msg_dc_unlocking), +        // verify signed literal data +        MSG_VL (LogLevel.INFO, R.string.msg_vl), +        MSG_VL_ERROR_MISSING_SIGLIST (LogLevel.ERROR, R.string.msg_vl_error_no_siglist), +        MSG_VL_ERROR_MISSING_LITERAL (LogLevel.ERROR, R.string.msg_vl_error_missing_literal), +        MSG_VL_ERROR_MISSING_KEY (LogLevel.ERROR, R.string.msg_vl_error_wrong_key), +        MSG_VL_CLEAR_SIGNATURE_CHECK (LogLevel.DEBUG, R.string.msg_vl_clear_signature_check), +        MSG_VL_ERROR_INTEGRITY_CHECK (LogLevel.ERROR, R.string.msg_vl_error_integrity_check), +        MSG_VL_OK (LogLevel.OK, R.string.msg_vl_ok), +          // signencrypt          MSG_SE_ASYMMETRIC (LogLevel.INFO, R.string.msg_se_asymmetric),          MSG_SE_CLEARSIGN_ONLY (LogLevel.DEBUG, R.string.msg_se_clearsign_only), diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/OpenPgpSignatureResultBuilder.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/OpenPgpSignatureResultBuilder.java index aa324c7ed..fc5064e79 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/OpenPgpSignatureResultBuilder.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/OpenPgpSignatureResultBuilder.java @@ -84,10 +84,6 @@ public class OpenPgpSignatureResultBuilder {          this.mUserIds = userIds;      } -    public boolean isValidSignature() { -        return mValidSignature; -    } -      public void initValid(CanonicalizedPublicKeyRing signingRing,                            CanonicalizedPublicKey signingKey) {          setSignatureAvailable(true); diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/PgpDecryptVerify.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/PgpDecryptVerify.java index 4f086c2a6..5589a3521 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/PgpDecryptVerify.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/pgp/PgpDecryptVerify.java @@ -22,6 +22,7 @@ import android.content.Context;  import android.webkit.MimeTypeMap;  import org.openintents.openpgp.OpenPgpMetadata; +import org.openintents.openpgp.OpenPgpSignatureResult;  import org.spongycastle.bcpg.ArmoredInputStream;  import org.spongycastle.openpgp.PGPCompressedData;  import org.spongycastle.openpgp.PGPEncryptedData; @@ -48,6 +49,7 @@ import org.sufficientlysecure.keychain.Constants;  import org.sufficientlysecure.keychain.R;  import org.sufficientlysecure.keychain.operations.BaseOperation;  import org.sufficientlysecure.keychain.pgp.exception.PgpGeneralException; +import org.sufficientlysecure.keychain.pgp.exception.PgpKeyNotFoundException;  import org.sufficientlysecure.keychain.provider.KeychainContract.KeyRings;  import org.sufficientlysecure.keychain.provider.ProviderHelper;  import org.sufficientlysecure.keychain.operations.results.DecryptVerifyResult; @@ -83,6 +85,8 @@ public class PgpDecryptVerify extends BaseOperation {      private Set<Long> mAllowedKeyIds;      private boolean mDecryptMetadataOnly;      private byte[] mDecryptedSessionKey; +    private String mRequiredSignerFingerprint; +    private boolean mSignedLiteralData;      private PgpDecryptVerify(Builder builder) {          super(builder.mContext, builder.mProviderHelper, builder.mProgressable); @@ -96,6 +100,8 @@ public class PgpDecryptVerify extends BaseOperation {          this.mAllowedKeyIds = builder.mAllowedKeyIds;          this.mDecryptMetadataOnly = builder.mDecryptMetadataOnly;          this.mDecryptedSessionKey = builder.mDecryptedSessionKey; +        this.mSignedLiteralData = builder.mSignedLiteralData; +        this.mRequiredSignerFingerprint = builder.mRequiredSignerFingerprint;      }      public static class Builder { @@ -112,6 +118,8 @@ public class PgpDecryptVerify extends BaseOperation {          private Set<Long> mAllowedKeyIds = null;          private boolean mDecryptMetadataOnly = false;          private byte[] mDecryptedSessionKey = null; +        private String mRequiredSignerFingerprint = null; +        private boolean mSignedLiteralData = false;          public Builder(Context context, ProviderHelper providerHelper,                         Progressable progressable, @@ -123,6 +131,24 @@ public class PgpDecryptVerify extends BaseOperation {              mOutStream = outStream;          } +        /** +         * This is used when verifying signed literals to check that they are signed with +         *  the required key +         */ +        public Builder setRequiredSignerFingerprint(String fingerprint) { +            mRequiredSignerFingerprint = fingerprint; +            return this; +        } + +        /** +         * This is to force a mode where the message is just the signature key id and +         *  then a literal data packet; used in Keybase.io proofs +         */ +        public Builder setSignedLiteralData(boolean signedLiteralData) { +            mSignedLiteralData = signedLiteralData; +            return this; +        } +          public Builder setAllowSymmetricDecryption(boolean allowSymmetricDecryption) {              mAllowSymmetricDecryption = allowSymmetricDecryption;              return this; @@ -174,7 +200,9 @@ public class PgpDecryptVerify extends BaseOperation {                  // it is ascii armored                  Log.d(Constants.TAG, "ASCII Armor Header Line: " + aIn.getArmorHeaderLine()); -                if (aIn.isClearText()) { +                if (mSignedLiteralData) { +                    return verifySignedLiteralData(aIn, 0); +                } else if (aIn.isClearText()) {                      // a cleartext signature, verify it with the other method                      return verifyCleartextSignature(aIn, 0);                  } @@ -196,6 +224,136 @@ public class PgpDecryptVerify extends BaseOperation {      }      /** +     * Verify Keybase.io style signed literal data +     */ +    private DecryptVerifyResult verifySignedLiteralData(InputStream in, int indent) throws IOException, PGPException { +        OperationLog log = new OperationLog(); +        log.add(LogType.MSG_VL, indent); + +        // thinking that the proof-fetching operation is going to take most of the time +        updateProgress(R.string.progress_reading_data, 75, 100); + +        PGPObjectFactory pgpF = new PGPObjectFactory(in, new JcaKeyFingerprintCalculator()); +        Object o = pgpF.nextObject(); +        if (o instanceof PGPCompressedData) { +            log.add(LogType.MSG_DC_CLEAR_DECOMPRESS, indent + 1); + +            pgpF = new PGPObjectFactory(((PGPCompressedData) o).getDataStream(), new JcaKeyFingerprintCalculator()); +            o = pgpF.nextObject(); +            updateProgress(R.string.progress_decompressing_data, 80, 100); +        } + +        // all we want to see is a OnePassSignatureList followed by LiteralData +        if (!(o instanceof PGPOnePassSignatureList)) { +            log.add(LogType.MSG_VL_ERROR_MISSING_SIGLIST, indent); +            return new DecryptVerifyResult(DecryptVerifyResult.RESULT_ERROR, log); +        } +        PGPOnePassSignatureList sigList = (PGPOnePassSignatureList) o; + +        // go through all signatures (should be just one), make sure we have +        //  the key and it matches the one we’re looking for +        CanonicalizedPublicKeyRing signingRing = null; +        CanonicalizedPublicKey signingKey = null; +        int signatureIndex = -1; +        for (int i = 0; i < sigList.size(); ++i) { +            try { +                long sigKeyId = sigList.get(i).getKeyID(); +                signingRing = mProviderHelper.getCanonicalizedPublicKeyRing( +                        KeyRings.buildUnifiedKeyRingsFindBySubkeyUri(sigKeyId) +                ); +                signingKey = signingRing.getPublicKey(sigKeyId); +                signatureIndex = i; +            } catch (ProviderHelper.NotFoundException e) { +                Log.d(Constants.TAG, "key not found, trying next signature..."); +            } +        } + +        // there has to be a key, and it has to be the right one +        if (signingKey == null) { +            log.add(LogType.MSG_VL_ERROR_MISSING_KEY, indent); +            Log.d(Constants.TAG, "Failed to find key in signed-literal message"); +            return new DecryptVerifyResult(DecryptVerifyResult.RESULT_ERROR, log); +        } + +        String fingerprint = KeyFormattingUtils.convertFingerprintToHex(signingRing.getFingerprint()); +        if (!(mRequiredSignerFingerprint.equals(fingerprint))) { +            log.add(LogType.MSG_VL_ERROR_MISSING_KEY, indent); +            Log.d(Constants.TAG, "Fingerprint mismatch; wanted " + mRequiredSignerFingerprint + +                    " got " + fingerprint + "!"); +            return new DecryptVerifyResult(DecryptVerifyResult.RESULT_ERROR, log); +        } + +        OpenPgpSignatureResultBuilder signatureResultBuilder = new OpenPgpSignatureResultBuilder(); + +        PGPOnePassSignature signature = sigList.get(signatureIndex); +        signatureResultBuilder.initValid(signingRing, signingKey); + +        JcaPGPContentVerifierBuilderProvider contentVerifierBuilderProvider = +                new JcaPGPContentVerifierBuilderProvider() +                        .setProvider(Constants.BOUNCY_CASTLE_PROVIDER_NAME); +        signature.init(contentVerifierBuilderProvider, signingKey.getPublicKey()); + +        o = pgpF.nextObject(); + +        if (!(o instanceof PGPLiteralData)) { +            log.add(LogType.MSG_VL_ERROR_MISSING_LITERAL, indent); +            return new DecryptVerifyResult(DecryptVerifyResult.RESULT_ERROR, log); +        } + +        PGPLiteralData literalData = (PGPLiteralData) o; + +        log.add(LogType.MSG_DC_CLEAR_DATA, indent + 1); +        updateProgress(R.string.progress_decrypting, 85, 100); + +        InputStream dataIn = literalData.getInputStream(); + +        int length; +        byte[] buffer = new byte[1 << 16]; +        while ((length = dataIn.read(buffer)) > 0) { +            mOutStream.write(buffer, 0, length); +            signature.update(buffer, 0, length); +        } + +        updateProgress(R.string.progress_verifying_signature, 95, 100); +        log.add(LogType.MSG_VL_CLEAR_SIGNATURE_CHECK, indent + 1); + +        PGPSignatureList signatureList = (PGPSignatureList) pgpF.nextObject(); +        PGPSignature messageSignature = signatureList.get(signatureIndex); + +        // these are not cleartext signatures! +        // TODO: what about binary signatures? +        signatureResultBuilder.setSignatureOnly(false); + +        // Verify signature and check binding signatures +        boolean validSignature = signature.verify(messageSignature); +        if (validSignature) { +            log.add(LogType.MSG_DC_CLEAR_SIGNATURE_OK, indent + 1); +        } else { +            log.add(LogType.MSG_DC_CLEAR_SIGNATURE_BAD, indent + 1); +        } +        signatureResultBuilder.setValidSignature(validSignature); + +        OpenPgpSignatureResult signatureResult = signatureResultBuilder.build(); + +        if (signatureResult.getStatus() != OpenPgpSignatureResult.SIGNATURE_SUCCESS_CERTIFIED +                && signatureResult.getStatus() != OpenPgpSignatureResult.SIGNATURE_SUCCESS_UNCERTIFIED) { +            log.add(LogType.MSG_VL_ERROR_INTEGRITY_CHECK, indent); +            return new DecryptVerifyResult(DecryptVerifyResult.RESULT_ERROR, log); +        } + +        updateProgress(R.string.progress_done, 100, 100); + +        log.add(LogType.MSG_VL_OK, indent); + +        // Return a positive result, with metadata and verification info +        DecryptVerifyResult result = +                new DecryptVerifyResult(DecryptVerifyResult.RESULT_OK, log); +        result.setSignatureResult(signatureResult); +        return result; +    } + + +    /**       * Decrypt and/or verifies binary or ascii armored pgp       */      private DecryptVerifyResult decryptVerify(InputStream in, int indent) throws IOException, PGPException { @@ -619,6 +777,8 @@ public class PgpDecryptVerify extends BaseOperation {              metadata = null;          } +        OpenPgpSignatureResult signatureResult = signatureResultBuilder.build(); +          if (encryptedData.isIntegrityProtected()) {              updateProgress(R.string.progress_verifying_integrity, 95, 100); @@ -632,7 +792,8 @@ public class PgpDecryptVerify extends BaseOperation {              // If no valid signature is present:              // Handle missing integrity protection like failed integrity protection!              // The MDC packet can be stripped by an attacker! -            if (!signatureResultBuilder.isValidSignature()) { +            if (signatureResult.getStatus() != OpenPgpSignatureResult.SIGNATURE_SUCCESS_CERTIFIED +                    && signatureResult.getStatus() != OpenPgpSignatureResult.SIGNATURE_SUCCESS_UNCERTIFIED) {                  log.add(LogType.MSG_DC_ERROR_INTEGRITY_CHECK, indent);                  return new DecryptVerifyResult(DecryptVerifyResult.RESULT_ERROR, log);              } @@ -646,7 +807,7 @@ public class PgpDecryptVerify extends BaseOperation {          DecryptVerifyResult result =                  new DecryptVerifyResult(DecryptVerifyResult.RESULT_OK, log);          result.setDecryptMetadata(metadata); -        result.setSignatureResult(signatureResultBuilder.build()); +        result.setSignatureResult(signatureResult);          return result;      } diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentService.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentService.java index 8c5050fdf..dc9592710 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentService.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentService.java @@ -26,57 +26,71 @@ import android.os.Message;  import android.os.Messenger;  import android.os.RemoteException; +import com.textuality.keybase.lib.Proof; +import com.textuality.keybase.lib.prover.Prover; + +import org.json.JSONObject; +import org.spongycastle.openpgp.PGPUtil;  import org.sufficientlysecure.keychain.Constants;  import org.sufficientlysecure.keychain.R; +import org.sufficientlysecure.keychain.keyimport.HkpKeyserver; +import org.sufficientlysecure.keychain.keyimport.Keyserver; +import org.sufficientlysecure.keychain.keyimport.ParcelableKeyRing;  import org.sufficientlysecure.keychain.operations.CertifyOperation;  import org.sufficientlysecure.keychain.operations.DeleteOperation; +import org.sufficientlysecure.keychain.operations.ImportExportOperation; +import org.sufficientlysecure.keychain.operations.results.CertifyResult; +import org.sufficientlysecure.keychain.operations.results.ConsolidateResult; +import org.sufficientlysecure.keychain.operations.results.DecryptVerifyResult;  import org.sufficientlysecure.keychain.operations.results.DeleteResult; +import org.sufficientlysecure.keychain.operations.results.EditKeyResult;  import org.sufficientlysecure.keychain.operations.results.ExportResult; -import org.sufficientlysecure.keychain.pgp.exception.PgpKeyNotFoundException; -import org.sufficientlysecure.keychain.operations.results.CertifyResult; -import org.sufficientlysecure.keychain.util.FileHelper; -import org.sufficientlysecure.keychain.util.ParcelableFileCache.IteratorWithSize; -import org.sufficientlysecure.keychain.util.Preferences; -import org.sufficientlysecure.keychain.keyimport.HkpKeyserver; -import org.sufficientlysecure.keychain.keyimport.Keyserver; -import org.sufficientlysecure.keychain.keyimport.ParcelableKeyRing; +import org.sufficientlysecure.keychain.operations.results.ImportKeyResult; +import org.sufficientlysecure.keychain.operations.results.OperationResult; +import org.sufficientlysecure.keychain.operations.results.OperationResult.LogType; +import org.sufficientlysecure.keychain.operations.results.OperationResult.OperationLog; +import org.sufficientlysecure.keychain.operations.results.SaveKeyringResult; +import org.sufficientlysecure.keychain.operations.results.SignEncryptResult;  import org.sufficientlysecure.keychain.pgp.CanonicalizedPublicKeyRing;  import org.sufficientlysecure.keychain.pgp.CanonicalizedSecretKeyRing;  import org.sufficientlysecure.keychain.pgp.PgpDecryptVerify; -import org.sufficientlysecure.keychain.operations.results.DecryptVerifyResult;  import org.sufficientlysecure.keychain.pgp.PgpHelper; -import org.sufficientlysecure.keychain.operations.ImportExportOperation;  import org.sufficientlysecure.keychain.pgp.PgpKeyOperation;  import org.sufficientlysecure.keychain.pgp.PgpSignEncrypt;  import org.sufficientlysecure.keychain.pgp.Progressable;  import org.sufficientlysecure.keychain.pgp.UncachedKeyRing;  import org.sufficientlysecure.keychain.pgp.exception.PgpGeneralException;  import org.sufficientlysecure.keychain.pgp.exception.PgpGeneralMsgIdException; +import org.sufficientlysecure.keychain.pgp.exception.PgpKeyNotFoundException;  import org.sufficientlysecure.keychain.provider.CachedPublicKeyRing;  import org.sufficientlysecure.keychain.provider.ProviderHelper; -import org.sufficientlysecure.keychain.operations.results.OperationResult; -import org.sufficientlysecure.keychain.operations.results.OperationResult.LogType; -import org.sufficientlysecure.keychain.operations.results.OperationResult.OperationLog; -import org.sufficientlysecure.keychain.operations.results.ConsolidateResult; -import org.sufficientlysecure.keychain.operations.results.EditKeyResult; -import org.sufficientlysecure.keychain.operations.results.ImportKeyResult; -import org.sufficientlysecure.keychain.operations.results.SaveKeyringResult; -import org.sufficientlysecure.keychain.operations.results.SignEncryptResult; -import org.sufficientlysecure.keychain.util.ParcelableFileCache; +import org.sufficientlysecure.keychain.util.FileHelper;  import org.sufficientlysecure.keychain.util.InputData;  import org.sufficientlysecure.keychain.util.Log; +import org.sufficientlysecure.keychain.util.ParcelableFileCache; +import org.sufficientlysecure.keychain.util.ParcelableFileCache.IteratorWithSize; +import org.sufficientlysecure.keychain.util.Preferences;  import org.sufficientlysecure.keychain.util.ProgressScaler;  import java.io.ByteArrayInputStream;  import java.io.ByteArrayOutputStream;  import java.io.FileNotFoundException;  import java.io.IOException; +import java.io.InputStream;  import java.io.OutputStream;  import java.util.ArrayList;  import java.util.Date;  import java.util.Iterator; +import java.util.List;  import java.util.concurrent.atomic.AtomicBoolean; +import de.measite.minidns.Client; +import de.measite.minidns.DNSMessage; +import de.measite.minidns.Question; +import de.measite.minidns.Record; +import de.measite.minidns.record.Data; +import de.measite.minidns.record.TXT; +  /**   * This Service contains all important long lasting operations for OpenKeychain. It receives Intents with   * data from the activities or other apps, queues these intents, executes them, and stops itself @@ -93,6 +107,8 @@ public class KeychainIntentService extends IntentService implements Progressable      public static final String ACTION_DECRYPT_VERIFY = Constants.INTENT_PREFIX + "DECRYPT_VERIFY"; +    public static final String ACTION_VERIFY_KEYBASE_PROOF = Constants.INTENT_PREFIX + "VERIFY_KEYBASE_PROOF"; +      public static final String ACTION_DECRYPT_METADATA = Constants.INTENT_PREFIX + "DECRYPT_METADATA";      public static final String ACTION_EDIT_KEYRING = Constants.INTENT_PREFIX + "EDIT_KEYRING"; @@ -115,6 +131,7 @@ public class KeychainIntentService extends IntentService implements Progressable      // encrypt, decrypt, import export      public static final String TARGET = "target";      public static final String SOURCE = "source"; +      // possible targets:      public static final int IO_BYTES = 1;      public static final int IO_URI = 2; @@ -142,6 +159,10 @@ public class KeychainIntentService extends IntentService implements Progressable      public static final String DECRYPT_PASSPHRASE = "passphrase";      public static final String DECRYPT_NFC_DECRYPTED_SESSION_KEY = "nfc_decrypted_session_key"; +    // keybase proof +    public static final String KEYBASE_REQUIRED_FINGERPRINT = "keybase_required_fingerprint"; +    public static final String KEYBASE_PROOF = "keybase_proof"; +      // save keyring      public static final String EDIT_KEYRING_PARCEL = "save_parcel";      public static final String EDIT_KEYRING_PASSPHRASE = "passphrase"; @@ -286,6 +307,101 @@ public class KeychainIntentService extends IntentService implements Progressable                  sendErrorToHandler(e);              } +        } else if (ACTION_VERIFY_KEYBASE_PROOF.equals(action)) { +            try { +                Proof proof = new Proof(new JSONObject(data.getString(KEYBASE_PROOF))); +                setProgress(R.string.keybase_message_fetching_data, 0, 100); + +                Prover prover = Prover.findProverFor(proof); + +                if (prover == null) { +                    sendProofError(getString(R.string.keybase_no_prover_found) + ": " + proof.getPrettyName()); +                    return; +                } + +                if (!prover.fetchProofData()) { +                    sendProofError(prover.getLog(), getString(R.string.keybase_problem_fetching_evidence)); +                    return; +                } +                String requiredFingerprint = data.getString(KEYBASE_REQUIRED_FINGERPRINT); +                if (!prover.checkFingerprint(requiredFingerprint)) { +                    sendProofError(getString(R.string.keybase_key_mismatch)); +                    return; +                } + +                String domain = prover.dnsTxtCheckRequired(); +                if (domain != null) { +                    DNSMessage dnsQuery =  new Client().query(new Question(domain, Record.TYPE.TXT)); +                    if (dnsQuery == null) { +                        sendProofError(prover.getLog(), getString(R.string.keybase_dns_query_failure)); +                        return; +                    } +                    Record[] records = dnsQuery.getAnswers(); +                    List<List<byte[]>> extents = new ArrayList<List<byte[]>>(); +                    for (Record r : records) { +                        Data d = r.getPayload(); +                        if (d instanceof TXT) { +                            extents.add(((TXT) d).getExtents()); +                        } +                    } +                    if (!prover.checkDnsTxt(extents)) { +                        sendProofError(prover.getLog(), null); +                        return; +                    } +                } + +                byte[] messageBytes = prover.getPgpMessage().getBytes(); +                if (prover.rawMessageCheckRequired()) { +                    InputStream messageByteStream = PGPUtil.getDecoderStream(new ByteArrayInputStream(messageBytes)); +                    if (!prover.checkRawMessageBytes(messageByteStream)) { +                        sendProofError(prover.getLog(), null); +                        return; +                    } +                } + +                // kind of awkward, but this whole class wants to pull bytes out of “data” +                data.putInt(KeychainIntentService.TARGET, KeychainIntentService.IO_BYTES); +                data.putByteArray(KeychainIntentService.DECRYPT_CIPHERTEXT_BYTES, messageBytes); + +                InputData inputData = createDecryptInputData(data); +                OutputStream outStream = createCryptOutputStream(data); + +                PgpDecryptVerify.Builder builder = new PgpDecryptVerify.Builder( +                        this, new ProviderHelper(this), this, +                        inputData, outStream +                ); +                builder.setSignedLiteralData(true).setRequiredSignerFingerprint(requiredFingerprint); + +                DecryptVerifyResult decryptVerifyResult = builder.build().execute(); +                outStream.close(); + +                if (!decryptVerifyResult.success()) { +                    OperationLog log = decryptVerifyResult.getLog(); +                    OperationResult.LogEntryParcel lastEntry = null; +                    for (OperationResult.LogEntryParcel entry : log) { +                        lastEntry = entry; +                    } +                    sendProofError(getString(lastEntry.mType.getMsgId())); +                    return; +                } + +                if (!prover.validate(outStream.toString())) { +                    sendProofError(getString(R.string.keybase_message_payload_mismatch)); +                    return; +                } + +                Bundle resultData = new Bundle(); +                resultData.putString(KeychainIntentServiceHandler.DATA_MESSAGE, "OK"); + +                // these help the handler construct a useful human-readable message +                resultData.putString(KeychainIntentServiceHandler.KEYBASE_PROOF_URL, prover.getProofUrl()); +                resultData.putString(KeychainIntentServiceHandler.KEYBASE_PRESENCE_URL, prover.getPresenceUrl()); +                resultData.putString(KeychainIntentServiceHandler.KEYBASE_PRESENCE_LABEL, prover.getPresenceLabel()); +                sendMessageToHandler(KeychainIntentServiceHandler.MESSAGE_OKAY, resultData); +            } catch (Exception e) { +                sendErrorToHandler(e); +            } +          } else if (ACTION_DECRYPT_VERIFY.equals(action)) {              try { @@ -592,6 +708,22 @@ public class KeychainIntentService extends IntentService implements Progressable      } +    private void sendProofError(List<String> log, String label) { +        String msg = null; +        label = (label == null) ? "" : label + ": "; +        for (String m : log) { +            Log.e(Constants.TAG, label + m); +            msg = m; +        } +        sendProofError(label + msg); +    } + +    private void sendProofError(String msg) { +        Bundle bundle = new Bundle(); +        bundle.putString(KeychainIntentServiceHandler.DATA_ERROR, msg); +        sendMessageToHandler(KeychainIntentServiceHandler.MESSAGE_OKAY, bundle); +    } +      private void sendErrorToHandler(Exception e) {          // TODO: Implement a better exception handling here          // contextualize the exception, if necessary @@ -602,7 +734,6 @@ public class KeychainIntentService extends IntentService implements Progressable          } else {              message = e.getMessage();          } -          Log.d(Constants.TAG, "KeychainIntentService Exception: ", e);          Bundle data = new Bundle(); diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentServiceHandler.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentServiceHandler.java index 180020d0b..fc65757f5 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentServiceHandler.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/service/KeychainIntentServiceHandler.java @@ -45,6 +45,11 @@ public class KeychainIntentServiceHandler extends Handler {      public static final String DATA_MESSAGE = "message";      public static final String DATA_MESSAGE_ID = "message_id"; +    // keybase proof specific +    public static final String KEYBASE_PROOF_URL = "keybase_proof_url"; +    public static final String KEYBASE_PRESENCE_URL = "keybase_presence_url"; +    public static final String KEYBASE_PRESENCE_LABEL = "keybase_presence_label"; +      Activity mActivity;      ProgressDialogFragment mProgressDialogFragment; diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyActivity.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyActivity.java index 0bc75b3a9..a7ba4accf 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyActivity.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyActivity.java @@ -78,6 +78,7 @@ public class ViewKeyActivity extends ActionBarActivity implements      public static final String EXTRA_SELECTED_TAB = "selected_tab";      public static final int TAB_MAIN = 0;      public static final int TAB_SHARE = 1; +    public static final int TAB_TRUST = 2;      // view      private ViewPager mViewPager; @@ -183,6 +184,11 @@ public class ViewKeyActivity extends ActionBarActivity implements          mTabsAdapter.addTab(ViewKeyShareFragment.class,                  shareBundle, getString(R.string.key_view_tab_share)); +        Bundle trustBundle = new Bundle(); +        trustBundle.putParcelable(ViewKeyMainFragment.ARG_DATA_URI, dataUri); +        mTabsAdapter.addTab(ViewKeyTrustFragment.class, trustBundle, +                getString(R.string.key_view_tab_trust)); +          // update layout after operations          mSlidingTabLayout.setViewPager(mViewPager);      } diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyTrustFragment.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyTrustFragment.java new file mode 100644 index 000000000..677646441 --- /dev/null +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/ViewKeyTrustFragment.java @@ -0,0 +1,458 @@ +/* + * Copyright (C) 2014 Tim Bray <tbray@textuality.com> + * + * 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.sufficientlysecure.keychain.ui; + +import android.app.ProgressDialog; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Message; +import android.os.Messenger; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.StyleSpan; +import android.text.style.URLSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import com.textuality.keybase.lib.KeybaseException; +import com.textuality.keybase.lib.Proof; +import com.textuality.keybase.lib.User; + +import org.sufficientlysecure.keychain.Constants; +import org.sufficientlysecure.keychain.R; +import org.sufficientlysecure.keychain.provider.KeychainContract.KeyRings; +import org.sufficientlysecure.keychain.service.KeychainIntentService; +import org.sufficientlysecure.keychain.service.KeychainIntentServiceHandler; +import org.sufficientlysecure.keychain.ui.util.KeyFormattingUtils; +import org.sufficientlysecure.keychain.util.Log; + +import java.util.ArrayList; +import java.util.Date; +import java.util.Hashtable; +import java.util.List; + +public class ViewKeyTrustFragment extends LoaderFragment implements +        LoaderManager.LoaderCallbacks<Cursor> { + +    private View mStartSearch; +    private TextView mTrustReadout; +    private TextView mReportHeader; +    private TableLayout mProofListing; +    private LayoutInflater mInflater; +    private View mProofVerifyHeader; +    private TextView mProofVerifyDetail; + +    private static final int LOADER_ID_DATABASE = 1; + +    // for retrieving the key we’re working on +    private Uri mDataUri; + +    @Override +    public View onCreateView(LayoutInflater inflater, ViewGroup superContainer, Bundle savedInstanceState) { +        View root = super.onCreateView(inflater, superContainer, savedInstanceState); +        View view = inflater.inflate(R.layout.view_key_trust_fragment, getContainer()); +        mInflater = inflater; + +        mTrustReadout = (TextView) view.findViewById(R.id.view_key_trust_readout); +        mStartSearch = view.findViewById(R.id.view_key_trust_search_cloud); +        mStartSearch.setEnabled(false); +        mReportHeader = (TextView) view.findViewById(R.id.view_key_trust_cloud_narrative); +        mProofListing = (TableLayout) view.findViewById(R.id.view_key_proof_list); +        mProofVerifyHeader = view.findViewById(R.id.view_key_proof_verify_header); +        mProofVerifyDetail = (TextView) view.findViewById(R.id.view_key_proof_verify_detail); +        mReportHeader.setVisibility(View.GONE); +        mProofListing.setVisibility(View.GONE); +        mProofVerifyHeader.setVisibility(View.GONE); +        mProofVerifyDetail.setVisibility(View.GONE); + +        return root; +    } + +    @Override +    public void onActivityCreated(Bundle savedInstanceState) { +        super.onActivityCreated(savedInstanceState); + +        Uri dataUri = getArguments().getParcelable(ViewKeyMainFragment.ARG_DATA_URI); +        if (dataUri == null) { +            Log.e(Constants.TAG, "Data missing. Should be Uri of key!"); +            getActivity().finish(); +            return; +        } +        mDataUri = dataUri; + +        // retrieve the key from the database +        getLoaderManager().initLoader(LOADER_ID_DATABASE, null, this); +    } + +    static final String[] TRUST_PROJECTION = new String[]{ +            KeyRings._ID, KeyRings.FINGERPRINT, KeyRings.IS_REVOKED, KeyRings.EXPIRY, +            KeyRings.HAS_ANY_SECRET, KeyRings.VERIFIED +    }; +    static final int INDEX_TRUST_FINGERPRINT = 1; +    static final int INDEX_TRUST_IS_REVOKED = 2; +    static final int INDEX_TRUST_EXPIRY = 3; +    static final int INDEX_UNIFIED_HAS_ANY_SECRET = 4; +    static final int INDEX_VERIFIED = 5; + +    public Loader<Cursor> onCreateLoader(int id, Bundle args) { +        setContentShown(false); + +        switch (id) { +            case LOADER_ID_DATABASE: { +                Uri baseUri = KeyRings.buildUnifiedKeyRingUri(mDataUri); +                return new CursorLoader(getActivity(), baseUri, TRUST_PROJECTION, null, null, null); +            } +            // decided to just use an AsyncTask for keybase, but maybe later +            default: +                return null; +        } +    } + +    public void onLoadFinished(Loader<Cursor> loader, Cursor data) { +        /* TODO better error handling? May cause problems when a key is deleted, +         * because the notification triggers faster than the activity closes. +         */ +        // Avoid NullPointerExceptions... +        if (data.getCount() == 0) { +            return; +        } + +        boolean nothingSpecial = true; +        StringBuilder message = new StringBuilder(); + +        // Swap the new cursor in. (The framework will take care of closing the +        // old cursor once we return.) +        if (data.moveToFirst()) { + +            if (data.getInt(INDEX_UNIFIED_HAS_ANY_SECRET) != 0) { +                message.append(getString(R.string.key_trust_it_is_yours)).append("\n"); +                nothingSpecial = false; +            } else if (data.getInt(INDEX_VERIFIED) != 0) { +                message.append(getString(R.string.key_trust_already_verified)).append("\n"); +                nothingSpecial = false; +            } + +            // If this key is revoked, don’t trust it! +            if (data.getInt(INDEX_TRUST_IS_REVOKED) != 0) { +                message.append(getString(R.string.key_trust_revoked)). +                        append(getString(R.string.key_trust_old_keys)); + +                nothingSpecial = false; +            } else { +                Date expiryDate = new Date(data.getLong(INDEX_TRUST_EXPIRY) * 1000); +                if (!data.isNull(INDEX_TRUST_EXPIRY) && expiryDate.before(new Date())) { + +                    // if expired, don’t trust it! +                    message.append(getString(R.string.key_trust_expired)). +                            append(getString(R.string.key_trust_old_keys)); + +                    nothingSpecial = false; +                } +            } + +            if (nothingSpecial) { +                message.append(getString(R.string.key_trust_maybe)); +            } + +            final byte[] fp = data.getBlob(INDEX_TRUST_FINGERPRINT); +            final String fingerprint = KeyFormattingUtils.convertFingerprintToHex(fp); +            if (fingerprint != null) { + +                mStartSearch.setEnabled(true); +                mStartSearch.setOnClickListener(new View.OnClickListener() { +                    @Override +                    public void onClick(View view) { +                        mStartSearch.setEnabled(false); +                        new DescribeKey().execute(fingerprint); +                    } +                }); +            } +        } + +        mTrustReadout.setText(message); +        setContentShown(true); +    } + +    /** +     * This is called when the last Cursor provided to onLoadFinished() above is about to be closed. +     * We need to make sure we are no longer using it. +     */ +    public void onLoaderReset(Loader<Cursor> loader) { +        // no-op in this case I think +    } + +    class ResultPage { +        String mHeader; +        final List<CharSequence> mProofs; + +        public ResultPage(String header, List<CharSequence> proofs) { +            mHeader = header; +            mProofs = proofs; +        } +    } + +    // look for evidence from keybase in the background, make tabular version of result +    // +    private class DescribeKey extends AsyncTask<String, Void, ResultPage> { + +        @Override +        protected ResultPage doInBackground(String... args) { +            String fingerprint = args[0]; + +            final ArrayList<CharSequence> proofList = new ArrayList<CharSequence>(); +            final Hashtable<Integer, ArrayList<Proof>> proofs = new Hashtable<Integer, ArrayList<Proof>>(); +            try { +                User keybaseUser = User.findByFingerprint(fingerprint); +                for (Proof proof : keybaseUser.getProofs()) { +                    Integer proofType = proof.getType(); +                    appendIfOK(proofs, proofType, proof); +                } + +                // a one-liner in a modern programming language +                for (Integer proofType : proofs.keySet()) { +                    Proof[] x = {}; +                    Proof[] proofsFor = proofs.get(proofType).toArray(x); +                    if (proofsFor.length > 0) { +                        SpannableStringBuilder ssb = new SpannableStringBuilder(); +                        ssb.append(getProofNarrative(proofType)).append(" "); + +                        int i = 0; +                        while (i < proofsFor.length - 1) { +                            appendProofLinks(ssb, fingerprint, proofsFor[i]); +                            ssb.append(", "); +                            i++; +                        } +                        appendProofLinks(ssb, fingerprint, proofsFor[i]); +                        proofList.add(ssb); +                    } +                } + +            } catch (KeybaseException ignored) { +            } + +            return new ResultPage(getString(R.string.key_trust_results_prefix), proofList); +        } + +        private SpannableStringBuilder appendProofLinks(SpannableStringBuilder ssb, final String fingerprint, final Proof proof) throws KeybaseException { +            int startAt = ssb.length(); +            String handle = proof.getHandle(); +            ssb.append(handle); +            ssb.setSpan(new URLSpan(proof.getServiceUrl()), startAt, startAt + handle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); +            if (haveProofFor(proof.getType())) { +                ssb.append("\u00a0["); +                startAt = ssb.length(); +                String verify = getString(R.string.keybase_verify); +                ssb.append(verify); +                ClickableSpan clicker = new ClickableSpan() { +                    @Override +                    public void onClick(View view) { +                        verify(proof, fingerprint); +                    } +                }; +                ssb.setSpan(clicker, startAt, startAt + verify.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); +                ssb.append("]"); +            } +            return ssb; +        } + +        @Override +        protected void onPostExecute(ResultPage result) { +            super.onPostExecute(result); +            if (result.mProofs.isEmpty()) { +                result.mHeader = getActivity().getString(R.string.key_trust_no_cloud_evidence); +            } + +            mStartSearch.setVisibility(View.GONE); +            mReportHeader.setVisibility(View.VISIBLE); +            mProofListing.setVisibility(View.VISIBLE); +            mReportHeader.setText(result.mHeader); + +            int rowNumber = 1; +            for (CharSequence s : result.mProofs) { +                TableRow row = (TableRow) mInflater.inflate(R.layout.view_key_keybase_proof, null); +                TextView number = (TextView) row.findViewById(R.id.proof_number); +                TextView text = (TextView) row.findViewById(R.id.proof_text); +                number.setText(Integer.toString(rowNumber++) + ". "); +                text.setText(s); +                text.setMovementMethod(LinkMovementMethod.getInstance()); +                mProofListing.addView(row); +            } + +            // mSearchReport.loadDataWithBaseURL("file:///android_res/drawable/", s, "text/html", "UTF-8", null); +        } +    } + +    private String getProofNarrative(int proofType) { +        int stringIndex; +        switch (proofType) { +            case Proof.PROOF_TYPE_TWITTER: stringIndex = R.string.keybase_narrative_twitter; break; +            case Proof.PROOF_TYPE_GITHUB: stringIndex = R.string.keybase_narrative_github; break; +            case Proof.PROOF_TYPE_DNS: stringIndex = R.string.keybase_narrative_dns; break; +            case Proof.PROOF_TYPE_WEB_SITE: stringIndex = R.string.keybase_narrative_web_site; break; +            case Proof.PROOF_TYPE_HACKERNEWS: stringIndex = R.string.keybase_narrative_hackernews; break; +            case Proof.PROOF_TYPE_COINBASE: stringIndex = R.string.keybase_narrative_coinbase; break; +            case Proof.PROOF_TYPE_REDDIT: stringIndex = R.string.keybase_narrative_reddit; break; +            default: stringIndex = R.string.keybase_narrative_unknown; +        } +        return getActivity().getString(stringIndex); +    } + +    private void appendIfOK(Hashtable<Integer, ArrayList<Proof>> table, Integer proofType, Proof proof) throws KeybaseException { +        ArrayList<Proof> list = table.get(proofType); +        if (list == null) { +            list = new ArrayList<Proof>(); +            table.put(proofType, list); +        } +        list.add(proof); +    } + +    // which proofs do we have working verifiers for? +    private boolean haveProofFor(int proofType) { +        switch (proofType) { +            case Proof.PROOF_TYPE_TWITTER: return true; +            case Proof.PROOF_TYPE_GITHUB: return true; +            case Proof.PROOF_TYPE_DNS: return true; +            case Proof.PROOF_TYPE_WEB_SITE: return true; +            case Proof.PROOF_TYPE_HACKERNEWS: return true; +            case Proof.PROOF_TYPE_COINBASE: return true; +            case Proof.PROOF_TYPE_REDDIT: return true; +            default: return false; +        } +    } + +    private void verify(final Proof proof, final String fingerprint) { +        Intent intent = new Intent(getActivity(), KeychainIntentService.class); +        Bundle data = new Bundle(); +        intent.setAction(KeychainIntentService.ACTION_VERIFY_KEYBASE_PROOF); + +        data.putString(KeychainIntentService.KEYBASE_PROOF, proof.toString()); +        data.putString(KeychainIntentService.KEYBASE_REQUIRED_FINGERPRINT, fingerprint); +        intent.putExtra(KeychainIntentService.EXTRA_DATA, data); + +        mProofVerifyDetail.setVisibility(View.GONE); + +        // Create a new Messenger for the communication back after proof work is done +        // +        KeychainIntentServiceHandler handler = new KeychainIntentServiceHandler(getActivity(), +                getString(R.string.progress_verifying_signature), ProgressDialog.STYLE_HORIZONTAL) { +            public void handleMessage(Message message) { +                // handle messages by standard KeychainIntentServiceHandler first +                super.handleMessage(message); + +                if (message.arg1 == KeychainIntentServiceHandler.MESSAGE_OKAY) { +                    Bundle returnData = message.getData(); +                    String msg = returnData.getString(KeychainIntentServiceHandler.DATA_MESSAGE); +                    SpannableStringBuilder ssb = new SpannableStringBuilder(); + +                    if ((msg != null) && msg.equals("OK")) { + +                        //yay +                        String proofUrl = returnData.getString(KeychainIntentServiceHandler.KEYBASE_PROOF_URL); +                        String presenceUrl = returnData.getString(KeychainIntentServiceHandler.KEYBASE_PRESENCE_URL); +                        String presenceLabel = returnData.getString(KeychainIntentServiceHandler.KEYBASE_PRESENCE_LABEL); + +                        String proofLabel; +                        switch (proof.getType()) { +                            case Proof.PROOF_TYPE_TWITTER: +                                proofLabel = getString(R.string.keybase_twitter_proof); +                                break; +                            case Proof.PROOF_TYPE_DNS: +                                proofLabel = getString(R.string.keybase_dns_proof); +                                break; +                            case Proof.PROOF_TYPE_WEB_SITE: +                                proofLabel = getString(R.string.keybase_web_site_proof); +                                break; +                            case Proof.PROOF_TYPE_GITHUB: +                                proofLabel = getString(R.string.keybase_github_proof); +                                break; +                            case Proof.PROOF_TYPE_REDDIT: +                                proofLabel = getString(R.string.keybase_reddit_proof); +                                break; +                            default: +                                proofLabel = getString(R.string.keybase_a_post); +                                break; +                        } + +                        ssb.append(getString(R.string.keybase_proof_succeeded)); +                        StyleSpan bold = new StyleSpan(Typeface.BOLD); +                        ssb.setSpan(bold, 0, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); +                        ssb.append("\n\n"); +                        int length = ssb.length(); +                        ssb.append(proofLabel); +                        if (proofUrl != null) { +                            URLSpan postLink = new URLSpan(proofUrl); +                            ssb.setSpan(postLink, length, length + proofLabel.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); +                        } +                        if (Proof.PROOF_TYPE_DNS == proof.getType()) { +                            ssb.append(" ").append(getString(R.string.keybase_for_the_domain)).append(" "); +                        } else { +                            ssb.append(" ").append(getString(R.string.keybase_fetched_from)).append(" "); +                        } +                        length = ssb.length(); +                        URLSpan presenceLink = new URLSpan(presenceUrl); +                        ssb.append(presenceLabel); +                        ssb.setSpan(presenceLink, length, length + presenceLabel.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); +                        if (Proof.PROOF_TYPE_REDDIT == proof.getType()) { +                            ssb.append(", "). +                                    append(getString(R.string.keybase_reddit_attribution)). +                                    append(" “").append(proof.getHandle()).append("”, "); +                        } +                        ssb.append(" ").append(getString(R.string.keybase_contained_signature)); +                    } else { +                        // verification failed! +                        msg = returnData.getString(KeychainIntentServiceHandler.DATA_ERROR); +                        ssb.append(getString(R.string.keybase_proof_failure)); +                        if (msg == null) { +                            msg = getString(R.string.keybase_unknown_proof_failure); +                        } +                        StyleSpan bold = new StyleSpan(Typeface.BOLD); +                        ssb.setSpan(bold, 0, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); +                        ssb.append("\n\n").append(msg); +                    } +                    mProofVerifyHeader.setVisibility(View.VISIBLE); +                    mProofVerifyDetail.setVisibility(View.VISIBLE); +                    mProofVerifyDetail.setMovementMethod(LinkMovementMethod.getInstance()); +                    mProofVerifyDetail.setText(ssb); +                } +            } +        }; + +        // Create a new Messenger for the communication back +        Messenger messenger = new Messenger(handler); +        intent.putExtra(KeychainIntentService.EXTRA_MESSENGER, messenger); + +        // show progress dialog +        handler.showProgressDialog(getActivity()); + +        // start service with intent +        getActivity().startService(intent); +    } +} diff --git a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/adapter/ImportKeysAdapter.java b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/adapter/ImportKeysAdapter.java index 6c81e9193..f3dce5823 100644 --- a/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/adapter/ImportKeysAdapter.java +++ b/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/ui/adapter/ImportKeysAdapter.java @@ -213,8 +213,24 @@ public class ImportKeysAdapter extends ArrayAdapter<ImportKeysListEntry> {              // destroyLoader view from holder              holder.userIdsList.removeAllViews(); +            // we want conventional gpg UserIDs first, then Keybase ”proofs”              HashMap<String, HashSet<String>> mergedUserIds = entry.getMergedUserIds(); -            for (Map.Entry<String, HashSet<String>> pair : mergedUserIds.entrySet()) { +            ArrayList<Map.Entry<String, HashSet<String>>> sortedIds = new ArrayList<Map.Entry<String, HashSet<String>>>(mergedUserIds.entrySet()); +            java.util.Collections.sort(sortedIds, new java.util.Comparator<Map.Entry<String, HashSet<String>>>() { +                @Override +                public int compare(Map.Entry<String, HashSet<String>> entry1, Map.Entry<String, HashSet<String>> entry2) { + +                    // sort keybase UserIds after non-Keybase +                    boolean e1IsKeybase = entry1.getKey().contains(":"); +                    boolean e2IsKeybase = entry2.getKey().contains(":"); +                    if (e1IsKeybase != e2IsKeybase) { +                        return (e1IsKeybase) ? 1 : -1; +                    } +                    return entry1.getKey().compareTo(entry2.getKey()); +                } +            }); + +            for (Map.Entry<String, HashSet<String>> pair : sortedIds) {                  String cUserId = pair.getKey();                  HashSet<String> cEmails = pair.getValue(); | 
