gecko/mobile/android/base/sync/jpake/JPakeClient.java

438 lines
14 KiB
Java

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