diff --git a/mobile/android/base/sync/Utils.java b/mobile/android/base/sync/Utils.java index f622735b8641..e12711b6bc84 100644 --- a/mobile/android/base/sync/Utils.java +++ b/mobile/android/base/sync/Utils.java @@ -504,4 +504,20 @@ public class Utils { final long duration = endMillis - startMillis; return new DecimalFormat("#0.00 seconds").format(((double) duration) / 1000); } -} + + /** + * This will take a string containing a UTF-8 representation of a UTF-8 + * byte array — e.g., "pïgéons1" — and return UTF-8 (e.g., "pïgéons1"). + * + * This is the format produced by desktop Firefox when exchanging credentials + * containing non-ASCII characters. + */ + public static String decodeUTF8(final String in) throws UnsupportedEncodingException { + final int length = in.length(); + final byte[] asciiBytes = new byte[length]; + for (int i = 0; i < length; ++i) { + asciiBytes[i] = (byte) in.codePointAt(i); + } + return new String(asciiBytes, "UTF-8"); + } +} \ No newline at end of file diff --git a/mobile/android/base/sync/net/BaseResource.java b/mobile/android/base/sync/net/BaseResource.java index 6abf6d3d0521..9827cf267604 100644 --- a/mobile/android/base/sync/net/BaseResource.java +++ b/mobile/android/base/sync/net/BaseResource.java @@ -129,14 +129,22 @@ public class BaseResource implements Resource { context.setAttribute(ClientContext.AUTH_CACHE, authCache); } + /** + * Return a Header object representing an Authentication header for HTTP Basic. + */ + public static Header getBasicAuthHeader(final String credentials) { + Credentials creds = new UsernamePasswordCredentials(credentials); + + // This must be UTF-8 to generate the same Basic Auth headers as desktop for non-ASCII passwords. + return BasicScheme.authenticate(creds, "UTF-8", false); + } + /** * Apply the provided credentials string to the provided request. * @param credentials a string, "user:pass". */ private static void applyCredentials(String credentials, HttpUriRequest request, HttpContext context) { - Credentials creds = new UsernamePasswordCredentials(credentials); - Header header = BasicScheme.authenticate(creds, "US-ASCII", false); - request.addHeader(header); + request.addHeader(getBasicAuthHeader(credentials)); Logger.trace(LOG_TAG, "Adding Basic Auth header."); } diff --git a/mobile/android/base/sync/setup/activities/SetupSyncActivity.java b/mobile/android/base/sync/setup/activities/SetupSyncActivity.java index 4a9f1e7cf6a5..857912379fbb 100644 --- a/mobile/android/base/sync/setup/activities/SetupSyncActivity.java +++ b/mobile/android/base/sync/setup/activities/SetupSyncActivity.java @@ -4,6 +4,7 @@ package org.mozilla.gecko.sync.setup.activities; +import java.io.UnsupportedEncodingException; import java.util.HashMap; import org.json.simple.JSONObject; @@ -11,6 +12,7 @@ import org.mozilla.gecko.R; import org.mozilla.gecko.sync.GlobalConstants; import org.mozilla.gecko.sync.Logger; import org.mozilla.gecko.sync.ThreadPool; +import org.mozilla.gecko.sync.Utils; import org.mozilla.gecko.sync.jpake.JPakeClient; import org.mozilla.gecko.sync.jpake.JPakeNoActivePairingException; import org.mozilla.gecko.sync.setup.Constants; @@ -392,6 +394,13 @@ public class SetupSyncActivity extends AccountAuthenticatorActivity { String syncKey = (String) jCreds.get(Constants.JSON_KEY_SYNCKEY); String serverURL = (String) jCreds.get(Constants.JSON_KEY_SERVER); + // The password we get is double-encoded. + try { + password = Utils.decodeUTF8(password); + } catch (UnsupportedEncodingException e) { + Logger.warn(LOG_TAG, "Unsupported encoding when decoding UTF-8 ASCII J-PAKE message. Ignoring."); + } + final SyncAccountParameters syncAccount = new SyncAccountParameters(mContext, mAccountManager, accountName, syncKey, password, serverURL); createAccountOnThread(syncAccount); diff --git a/src/test/java/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java b/src/test/java/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java new file mode 100644 index 000000000000..ea671f5a5c69 --- /dev/null +++ b/src/test/java/org/mozilla/android/sync/net/test/TestCredentialsEndToEnd.java @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.android.sync.net.test; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; + +import org.json.simple.parser.ParseException; +import org.junit.Test; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.net.BaseResource; + +import ch.boye.httpclientandroidlib.Header; + +/** + * Test the transfer of a UTF-8 string from desktop, and ensure that it results in the + * correct hashed Basic Auth header. + */ +public class TestCredentialsEndToEnd { + + public static final String REAL_PASSWORD = "pïgéons1"; + public static final String USERNAME = "utvm3mk6hnngiir2sp4jsxf2uvoycrv6"; + public static final String DESKTOP_PASSWORD_JSON = "{\"password\":\"pïgéons1\"}"; + public static final String BTOA_PASSWORD = "cMOvZ8Opb25zMQ=="; + public static final int DESKTOP_ASSERTED_SIZE = 10; + public static final String DESKTOP_BASIC_AUTH = "Basic dXR2bTNtazZobm5naWlyMnNwNGpzeGYydXZveWNydjY6cMOvZ8Opb25zMQ=="; + + private String getCreds(String password) { + Header authenticate = BaseResource.getBasicAuthHeader(USERNAME + ":" + password); + return authenticate.getValue(); + } + + @SuppressWarnings("static-method") + @Test + public void testUTF8() throws UnsupportedEncodingException { + final String in = "pïgéons1"; + final String out = "pïgéons1"; + assertEquals(out, Utils.decodeUTF8(in)); + } + + @Test + public void testAuthHeaderFromPassword() throws NonObjectJSONException, IOException, ParseException { + final ExtendedJSONObject parsed = new ExtendedJSONObject(DESKTOP_PASSWORD_JSON); + + final String password = parsed.getString("password"); + final String decoded = Utils.decodeUTF8(password); + + final byte[] expectedBytes = Utils.decodeBase64(BTOA_PASSWORD); + final String expected = new String(expectedBytes, "UTF-8"); + + assertEquals(DESKTOP_ASSERTED_SIZE, password.length()); + assertEquals(expected, decoded); + + System.out.println("Retrieved password: " + password); + System.out.println("Expected password: " + expected); + System.out.println("Rescued password: " + decoded); + + assertEquals(getCreds(expected), getCreds(decoded)); + assertEquals(getCreds(decoded), DESKTOP_BASIC_AUTH); + } + + // Note that we do *not* have a test for the J-PAKE setup process + // (SetupSyncActivity) that actually stores credentials and requires + // decodeUTF8. This will have to suffice. +}