michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.sync.jpake; michael@0: michael@0: import java.io.UnsupportedEncodingException; michael@0: import java.math.BigInteger; michael@0: import java.util.LinkedList; michael@0: import java.util.Queue; michael@0: michael@0: import org.json.simple.JSONObject; michael@0: import org.mozilla.apache.commons.codec.binary.Base64; michael@0: import org.mozilla.gecko.background.common.log.Logger; michael@0: import org.mozilla.gecko.sync.ExtendedJSONObject; michael@0: import org.mozilla.gecko.sync.ThreadPool; michael@0: import org.mozilla.gecko.sync.Utils; michael@0: import org.mozilla.gecko.sync.crypto.CryptoException; michael@0: import org.mozilla.gecko.sync.crypto.CryptoInfo; michael@0: import org.mozilla.gecko.sync.crypto.KeyBundle; michael@0: import org.mozilla.gecko.sync.crypto.NoKeyBundleException; michael@0: import org.mozilla.gecko.sync.jpake.stage.CompleteStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.ComputeFinalStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.ComputeKeyVerificationStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.ComputeStepOneStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.ComputeStepTwoStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.DecryptDataStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.DeleteChannel; michael@0: import org.mozilla.gecko.sync.jpake.stage.GetChannelStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.GetRequestStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.JPakeStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.PutRequestStage; michael@0: import org.mozilla.gecko.sync.jpake.stage.VerifyPairingStage; michael@0: import org.mozilla.gecko.sync.setup.Constants; michael@0: import org.mozilla.gecko.sync.setup.activities.SetupSyncActivity; michael@0: michael@0: import ch.boye.httpclientandroidlib.entity.StringEntity; michael@0: michael@0: public class JPakeClient { michael@0: michael@0: private static String LOG_TAG = "JPakeClient"; michael@0: michael@0: // J-PAKE constants. michael@0: public final static int REQUEST_TIMEOUT = 60 * 1000; // 1 min michael@0: public final static int KEYEXCHANGE_VERSION = 3; michael@0: public final static String JPAKE_VERIFY_VALUE = "0123456789ABCDEF"; michael@0: michael@0: private final static String JPAKE_SIGNERID_SENDER = "sender"; michael@0: private final static String JPAKE_SIGNERID_RECEIVER = "receiver"; michael@0: private final static int JPAKE_LENGTH_SECRET = 8; michael@0: private final static int JPAKE_LENGTH_CLIENTID = 256; michael@0: michael@0: private final static int MAX_TRIES = 10; michael@0: private final static int MAX_TRIES_FIRST_MSG = 300; michael@0: private final static int MAX_TRIES_LAST_MSG = 300; michael@0: michael@0: // J-PAKE session values. michael@0: public String clientId; michael@0: public String secret; michael@0: michael@0: public String myEtag; michael@0: public String mySignerId; michael@0: public String theirEtag; michael@0: public String theirSignerId; michael@0: public String jpakeServer; michael@0: michael@0: // J-PAKE state. michael@0: public boolean paired = false; michael@0: public boolean finished = false; michael@0: michael@0: // J-PAKE values. michael@0: public int jpakePollInterval; michael@0: public int jpakeMaxTries; michael@0: public String channel; michael@0: public volatile String channelUrl; michael@0: michael@0: // J-PAKE session data. michael@0: public KeyBundle myKeyBundle; michael@0: public JSONObject jCreds; michael@0: michael@0: public ExtendedJSONObject jOutgoing; michael@0: public ExtendedJSONObject jIncoming; michael@0: michael@0: public JPakeParty jParty; michael@0: public JPakeNumGenerator numGen; michael@0: michael@0: public int pollTries = 0; michael@0: michael@0: // UI controller. michael@0: private SetupSyncActivity controllerActivity; michael@0: private Queue stages; michael@0: michael@0: public JPakeClient(SetupSyncActivity activity) { michael@0: controllerActivity = activity; michael@0: jpakeServer = "https://setup.services.mozilla.com/"; michael@0: jpakePollInterval = 1 * 1000; // 1 second michael@0: jpakeMaxTries = MAX_TRIES; michael@0: michael@0: if (!jpakeServer.endsWith("/")) { michael@0: jpakeServer += "/"; michael@0: } michael@0: michael@0: setClientId(); michael@0: numGen = new JPakeNumGeneratorRandom(); michael@0: } michael@0: michael@0: /** michael@0: * Set up Sender sequence of stages for J-PAKE. (sender of credentials) michael@0: * michael@0: */ michael@0: michael@0: private void prepareSenderStages() { michael@0: Queue jStages = new LinkedList(); michael@0: jStages.add(new ComputeStepOneStage()); michael@0: jStages.add(new GetRequestStage()); michael@0: jStages.add(new PutRequestStage()); michael@0: jStages.add(new ComputeStepTwoStage()); michael@0: jStages.add(new GetRequestStage()); michael@0: jStages.add(new PutRequestStage()); michael@0: jStages.add(new ComputeFinalStage()); michael@0: jStages.add(new GetRequestStage()); michael@0: jStages.add(new VerifyPairingStage()); // Calls onPaired if verified. michael@0: michael@0: stages = jStages; michael@0: } michael@0: michael@0: /** michael@0: * Set up Receiver sequence of stages for J-PAKE. (receiver of credentials) michael@0: * michael@0: */ michael@0: private void prepareReceiverStages() { michael@0: Queue jStages = new LinkedList(); michael@0: jStages.add(new GetChannelStage()); michael@0: jStages.add(new ComputeStepOneStage()); michael@0: jStages.add(new PutRequestStage()); michael@0: jStages.add(new GetRequestStage()); michael@0: jStages.add(new JPakeStage() { michael@0: @Override michael@0: public void execute(JPakeClient jpakeClient) { michael@0: michael@0: // Notify controller that pairing has started. michael@0: jpakeClient.onPairingStart(); michael@0: michael@0: // Switch back to smaller time-out. michael@0: jpakeClient.jpakeMaxTries = JPakeClient.MAX_TRIES; michael@0: jpakeClient.runNextStage(); michael@0: } michael@0: }); michael@0: jStages.add(new ComputeStepTwoStage()); michael@0: jStages.add(new PutRequestStage()); michael@0: jStages.add(new GetRequestStage()); michael@0: jStages.add(new ComputeFinalStage()); michael@0: jStages.add(new ComputeKeyVerificationStage()); michael@0: jStages.add(new PutRequestStage()); michael@0: jStages.add(new JPakeStage() { michael@0: michael@0: @Override michael@0: public void execute(JPakeClient jpakeClient) { michael@0: jpakeMaxTries = MAX_TRIES_LAST_MSG; michael@0: jpakeClient.runNextStage(); michael@0: } michael@0: michael@0: }); michael@0: jStages.add(new GetRequestStage()); michael@0: jStages.add(new DecryptDataStage()); michael@0: jStages.add(new CompleteStage()); michael@0: michael@0: stages = jStages; michael@0: } michael@0: michael@0: /** michael@0: * michael@0: * Pairing using PIN provided on other device. Functionality available only michael@0: * when a Sync account has already been set up. michael@0: * michael@0: * @param pin michael@0: * 12-character string containing PIN entered by the user. michael@0: */ michael@0: public void pairWithPin(String pin) { michael@0: mySignerId = JPAKE_SIGNERID_SENDER; michael@0: theirSignerId = JPAKE_SIGNERID_RECEIVER; michael@0: jParty = new JPakeParty(mySignerId); michael@0: michael@0: // Extract secret and server channel. michael@0: secret = pin.substring(0, JPAKE_LENGTH_SECRET); michael@0: channel = pin.substring(JPAKE_LENGTH_SECRET); michael@0: channelUrl = jpakeServer + channel; michael@0: michael@0: prepareSenderStages(); michael@0: runNextStage(); michael@0: } michael@0: michael@0: /** michael@0: * michael@0: * Initiate pairing and receive data, without having received a PIN. The PIN michael@0: * will be generated and passed on to the controller to be displayed to the michael@0: * user. michael@0: * michael@0: * Starts J-PAKE protocol. michael@0: */ michael@0: public void receiveNoPin() { michael@0: mySignerId = JPAKE_SIGNERID_RECEIVER; michael@0: theirSignerId = JPAKE_SIGNERID_SENDER; michael@0: jParty = new JPakeParty(mySignerId); michael@0: michael@0: // TODO: fetch from prefs michael@0: jpakeMaxTries = MAX_TRIES_FIRST_MSG; michael@0: michael@0: createSecret(); michael@0: prepareReceiverStages(); michael@0: runNextStage(); michael@0: } michael@0: michael@0: /** michael@0: * Run next stage of J-PAKE. michael@0: */ michael@0: public void runNextStage() { michael@0: if (finished || stages.size() == 0) { michael@0: Logger.debug(LOG_TAG, "All stages complete."); michael@0: return; michael@0: } michael@0: JPakeStage currentStage = null; michael@0: try{ michael@0: currentStage = stages.remove(); michael@0: Logger.debug(LOG_TAG, "starting stage " + currentStage.toString()); michael@0: currentStage.execute(this); michael@0: } catch (Exception e) { michael@0: Logger.error(LOG_TAG, "Exception in stage " + currentStage, e); michael@0: abort("Stage exception."); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Abort J-PAKE. This can propagate an error from the stages, or result from michael@0: * UI abort (onPause, user abort) michael@0: * michael@0: * @param reason michael@0: * Reason for abort. michael@0: */ michael@0: public void abort(String reason) { michael@0: finished = true; michael@0: // We do not need to clean up the channel in the following cases: michael@0: if (Constants.JPAKE_ERROR_CHANNEL.equals(reason) || michael@0: Constants.JPAKE_ERROR_NETWORK.equals(reason) || michael@0: Constants.JPAKE_ERROR_NODATA.equals(reason) || michael@0: channelUrl == null) { michael@0: // We may leak a channel if the activity aborts sync while requesting the channel. michael@0: // The server, however, will delete the channel anyways after a certain time has passed. michael@0: displayAbort(reason); michael@0: } else { michael@0: // Delete channel, then call controller's displayAbort in callback. michael@0: new DeleteChannel().execute(this, reason); michael@0: } michael@0: } michael@0: michael@0: public void displayAbort(String reason) { michael@0: controllerActivity.displayAbort(reason); michael@0: } michael@0: michael@0: /* Static helper methods used by stages. */ michael@0: michael@0: /** michael@0: * Run on a different thread from the thread pool. michael@0: * michael@0: * @param run michael@0: * Runnable to run on separate thread. michael@0: */ michael@0: public static void runOnThread(Runnable run) { michael@0: ThreadPool.run(run); michael@0: } michael@0: michael@0: /** michael@0: * michael@0: * @param secretString michael@0: * String to convert to BigInteger michael@0: * @return BigInteger representation of secretString michael@0: * michael@0: * @throws UnsupportedEncodingException michael@0: */ michael@0: public static BigInteger secretAsBigInteger(String secretString) throws UnsupportedEncodingException { michael@0: return new BigInteger(secretString.getBytes("UTF-8")); michael@0: } michael@0: michael@0: /** michael@0: * Helper method for doing actual encryption. michael@0: * michael@0: * Input: String of JSONObject KeyBundle with keys for encryption michael@0: * michael@0: * Output: ExtendedJSONObject with IV, ciphertext, hmac (if sender) michael@0: * michael@0: * @throws CryptoException michael@0: * @throws UnsupportedEncodingException michael@0: */ michael@0: public static ExtendedJSONObject encryptPayload(String data, KeyBundle keyBundle, boolean makeHmac) michael@0: throws UnsupportedEncodingException, CryptoException { michael@0: if (keyBundle == null) { michael@0: throw new NoKeyBundleException(); michael@0: } michael@0: michael@0: byte[] cleartextBytes = data.getBytes("UTF-8"); michael@0: CryptoInfo encrypted = CryptoInfo.encrypt(cleartextBytes, keyBundle); michael@0: michael@0: ExtendedJSONObject payload = new ExtendedJSONObject(); michael@0: michael@0: String message64 = new String(Base64.encodeBase64(encrypted.getMessage())); michael@0: String iv64 = new String(Base64.encodeBase64(encrypted.getIV())); michael@0: michael@0: payload.put(Constants.JSON_KEY_CIPHERTEXT, message64); michael@0: payload.put(Constants.JSON_KEY_IV, iv64); michael@0: if (makeHmac) { michael@0: String hmacHex = Utils.byte2Hex(encrypted.getHMAC()); michael@0: payload.put(Constants.JSON_KEY_HMAC, hmacHex); michael@0: } michael@0: return payload; michael@0: } michael@0: michael@0: /* michael@0: * Helper for turning a JSON object into a payload. michael@0: * michael@0: * @param body JSONObject body to be converted to StringEntity. michael@0: * @return StringEntity representation of JSONObject. michael@0: * michael@0: * @throws UnsupportedEncodingException michael@0: */ michael@0: public static StringEntity jsonEntity(JSONObject body) michael@0: throws UnsupportedEncodingException { michael@0: StringEntity entity = new StringEntity(body.toJSONString(), "UTF-8"); michael@0: entity.setContentType("application/json"); michael@0: return entity; michael@0: } michael@0: michael@0: /* michael@0: * Controller methods. michael@0: */ michael@0: public void makeAndDisplayPin(String channel) { michael@0: controllerActivity.displayPin(secret + channel); michael@0: } michael@0: michael@0: public void onPairingStart() { michael@0: Logger.debug(LOG_TAG, "Pairing started."); michael@0: controllerActivity.onPairingStart(); michael@0: } michael@0: michael@0: public void onPaired() { michael@0: Logger.debug(LOG_TAG, "Pairing completed. Starting credential exchange."); michael@0: controllerActivity.onPaired(); michael@0: } michael@0: michael@0: public void complete(JSONObject credentials) { michael@0: controllerActivity.onComplete(credentials); michael@0: } michael@0: michael@0: /* michael@0: * Called from controller, with Sync credentials to be encrypted and sent. michael@0: */ michael@0: public void sendAndComplete(JSONObject jObj) michael@0: throws JPakeNoActivePairingException { michael@0: if (!paired || finished) { michael@0: Logger.error(LOG_TAG, "Can't send data, no active pairing!"); michael@0: throw new JPakeNoActivePairingException(); michael@0: } michael@0: stages.clear(); michael@0: stages.add(new PutRequestStage()); michael@0: stages.add(new CompleteStage()); michael@0: michael@0: // Encrypt data to send and set as jOutgoing. michael@0: String outData = jObj.toJSONString(); michael@0: encryptData(myKeyBundle, outData); michael@0: michael@0: // Start stages for sending credentials. michael@0: runNextStage(); michael@0: } michael@0: michael@0: /* Setup helper functions */ michael@0: michael@0: /* michael@0: * Generates and sets a clientId for communications with JPAKE setup server. michael@0: */ michael@0: private void setClientId() { michael@0: byte[] rBytes = Utils.generateRandomBytes(JPAKE_LENGTH_CLIENTID / 2); michael@0: StringBuilder id = new StringBuilder(); michael@0: michael@0: for (byte b : rBytes) { michael@0: String hexString = Integer.toHexString(b); michael@0: if (hexString.length() == 1) { michael@0: hexString = "0" + hexString; michael@0: } michael@0: int len = hexString.length(); michael@0: id.append(hexString.substring(len - 2, len)); michael@0: } michael@0: clientId = id.toString(); michael@0: } michael@0: michael@0: /* michael@0: * Generates and sets a JPAKE PIN to be displayed to user. michael@0: */ michael@0: private void createSecret() { michael@0: // 0-9a-z without 1,l,o,0 michael@0: String key = "23456789abcdefghijkmnpqrstuvwxyz"; michael@0: int keylen = key.length(); michael@0: michael@0: byte[] rBytes = Utils.generateRandomBytes(JPAKE_LENGTH_SECRET); michael@0: StringBuilder secret = new StringBuilder(); michael@0: for (byte b : rBytes) { michael@0: secret.append(key.charAt(Math.abs(b) * keylen / 256)); michael@0: } michael@0: this.secret = secret.toString(); michael@0: } michael@0: michael@0: /* michael@0: * michael@0: * Encrypt payload and package into jOutgoing for sending with a PUT request. michael@0: * michael@0: * @param keyBundle Encryption keys derived during J-PAKE. michael@0: * michael@0: * @param payload Credentials data to be encrypted. michael@0: */ michael@0: private void encryptData(KeyBundle keyBundle, String payload) { michael@0: Logger.debug(LOG_TAG, "Encrypting data."); michael@0: ExtendedJSONObject jPayload = null; michael@0: try { michael@0: jPayload = encryptPayload(payload, keyBundle, true); michael@0: } catch (UnsupportedEncodingException e) { michael@0: Logger.error(LOG_TAG, "Failed to encrypt data.", e); michael@0: abort(Constants.JPAKE_ERROR_INTERNAL); michael@0: return; michael@0: } catch (CryptoException e) { michael@0: Logger.error(LOG_TAG, "Failed to encrypt data.", e); michael@0: abort(Constants.JPAKE_ERROR_INTERNAL); michael@0: return; michael@0: } michael@0: jOutgoing = new ExtendedJSONObject(); michael@0: jOutgoing.put(Constants.JSON_KEY_TYPE, mySignerId + "3"); michael@0: jOutgoing.put(Constants.JSON_KEY_VERSION, KEYEXCHANGE_VERSION); michael@0: jOutgoing.put(Constants.JSON_KEY_PAYLOAD, jPayload.object); michael@0: } michael@0: }