diff --git a/mobile/android/base/android-services.mozbuild b/mobile/android/base/android-services.mozbuild index fdc9f474b667..d20b4f4a16ab 100644 --- a/mobile/android/base/android-services.mozbuild +++ b/mobile/android/base/android-services.mozbuild @@ -508,6 +508,7 @@ sync_java_files = [ 'background/fxa/FxAccountClient20.java', 'background/fxa/FxAccountClientException.java', 'background/fxa/FxAccountUtils.java', + 'background/fxa/SkewHandler.java', 'background/healthreport/Environment.java', 'background/healthreport/EnvironmentBuilder.java', 'background/healthreport/EnvironmentV1.java', diff --git a/mobile/android/base/background/fxa/FxAccountClient10.java b/mobile/android/base/background/fxa/FxAccountClient10.java index f06ee3a0a3b0..0b04e2322698 100644 --- a/mobile/android/base/background/fxa/FxAccountClient10.java +++ b/mobile/android/base/background/fxa/FxAccountClient10.java @@ -150,6 +150,7 @@ public class FxAccountClient10 { protected final byte[] tokenId; protected final byte[] reqHMACKey; protected final boolean payload; + protected final SkewHandler skewHandler; /** * Create a delegate for an un-authenticated resource. @@ -167,12 +168,13 @@ public class FxAccountClient10 { this.reqHMACKey = reqHMACKey; this.tokenId = tokenId; this.payload = authenticatePayload; + this.skewHandler = SkewHandler.getSkewHandlerForResource(resource); } @Override public AuthHeaderProvider getAuthHeaderProvider() { if (tokenId != null && reqHMACKey != null) { - return new HawkAuthHeaderProvider(Utils.byte2Hex(tokenId), reqHMACKey, payload); + return new HawkAuthHeaderProvider(Utils.byte2Hex(tokenId), reqHMACKey, payload, skewHandler.getSkewInSeconds()); } return super.getAuthHeaderProvider(); } @@ -182,9 +184,14 @@ public class FxAccountClient10 { final int status = response.getStatusLine().getStatusCode(); switch (status) { case 200: + skewHandler.updateSkew(response, now()); invokeHandleSuccess(status, response); return; default: + if (!skewHandler.updateSkew(response, now())) { + // If we couldn't update skew, but we got a failure, let's try clearing the skew. + skewHandler.resetSkew(); + } invokeHandleFailure(status, response); return; } @@ -242,6 +249,11 @@ public class FxAccountClient10 { } } + @SuppressWarnings("static-method") + public long now() { + return System.currentTimeMillis(); + } + public void createAccount(final String email, final byte[] stretchedPWBytes, final String srpSalt, final String mainSalt, final RequestDelegate delegate) { diff --git a/mobile/android/base/background/fxa/SkewHandler.java b/mobile/android/base/background/fxa/SkewHandler.java new file mode 100644 index 000000000000..9d0ad5e0301c --- /dev/null +++ b/mobile/android/base/background/fxa/SkewHandler.java @@ -0,0 +1,111 @@ +/* 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.background.fxa; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; + +import org.mozilla.gecko.background.common.log.Logger; +import org.mozilla.gecko.sync.net.Resource; + +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.impl.cookie.DateParseException; +import ch.boye.httpclientandroidlib.impl.cookie.DateUtils; + +public class SkewHandler { + private static final String LOG_TAG = "SkewHandler"; + protected volatile long skewMillis = 0L; + protected final String hostname; + + private static final HashMap skewHandlers = new HashMap(); + + public static SkewHandler getSkewHandlerForResource(final Resource resource) { + return getSkewHandlerForHostname(resource.getHostname()); + } + + public static SkewHandler getSkewHandlerFromEndpointString(final String url) throws URISyntaxException { + if (url == null) { + throw new IllegalArgumentException("url must not be null."); + } + URI u = new URI(url); + return getSkewHandlerForHostname(u.getHost()); + } + + public static synchronized SkewHandler getSkewHandlerForHostname(final String hostname) { + SkewHandler handler = skewHandlers.get(hostname); + if (handler == null) { + handler = new SkewHandler(hostname); + skewHandlers.put(hostname, handler); + } + return handler; + } + + public static synchronized void clearSkewHandlers() { + skewHandlers.clear(); + } + + public SkewHandler(final String hostname) { + this.hostname = hostname; + } + + public boolean updateSkewFromServerMillis(long millis, long now) { + skewMillis = millis - now; + Logger.debug(LOG_TAG, "Updated skew: " + skewMillis + "ms for hostname " + this.hostname); + return true; + } + + public boolean updateSkewFromHTTPDateString(String date, long now) { + try { + final long millis = DateUtils.parseDate(date).getTime(); + return updateSkewFromServerMillis(millis, now); + } catch (DateParseException e) { + Logger.warn(LOG_TAG, "Unexpected: invalid Date header from " + this.hostname); + return false; + } + } + + public boolean updateSkewFromDateHeader(Header header, long now) { + String date = header.getValue(); + if (null == date) { + Logger.warn(LOG_TAG, "Unexpected: null Date header from " + this.hostname); + return false; + } + return updateSkewFromHTTPDateString(date, now); + } + + /** + * Update our tracked skew value to account for the local clock differing from + * the server's. + * + * @param response + * the received HTTP response. + * @param now + * the current time in milliseconds. + * @return true if the skew value was updated, false otherwise. + */ + public boolean updateSkew(HttpResponse response, long now) { + Header header = response.getFirstHeader(HttpHeaders.DATE); + if (null == header) { + Logger.warn(LOG_TAG, "Unexpected: missing Date header from " + this.hostname); + return false; + } + return updateSkewFromDateHeader(header, now); + } + + public long getSkewInMillis() { + return skewMillis; + } + + public long getSkewInSeconds() { + return skewMillis / 1000; + } + + public void resetSkew() { + skewMillis = 0L; + } +} \ No newline at end of file diff --git a/mobile/android/base/fxa/authenticator/FxAccountLoginPolicy.java b/mobile/android/base/fxa/authenticator/FxAccountLoginPolicy.java index 364b3983aa63..c180f0149f08 100644 --- a/mobile/android/base/fxa/authenticator/FxAccountLoginPolicy.java +++ b/mobile/android/base/fxa/authenticator/FxAccountLoginPolicy.java @@ -15,6 +15,7 @@ import org.mozilla.gecko.background.fxa.FxAccountClient10.StatusResponse; import org.mozilla.gecko.background.fxa.FxAccountClient10.TwoKeys; import org.mozilla.gecko.background.fxa.FxAccountClient20; import org.mozilla.gecko.background.fxa.FxAccountClient20.LoginResponse; +import org.mozilla.gecko.background.fxa.SkewHandler; import org.mozilla.gecko.browserid.BrowserIDKeyPair; import org.mozilla.gecko.browserid.JSONWebTokenUtils; import org.mozilla.gecko.browserid.VerifyingPublicKey; @@ -57,6 +58,8 @@ public class FxAccountLoginPolicy { return new FxAccountClient20(serverURI, executor); } + private SkewHandler skewHandler; + /** * Check if this certificate is not worth generating an assertion from: for * example, because it is not well-formed, or it is already expired. @@ -83,6 +86,10 @@ public class FxAccountLoginPolicy { return false; } + protected long now() { + return System.currentTimeMillis(); + } + public enum AccountState { Invalid, NeedsSessionToken, @@ -167,6 +174,11 @@ public class FxAccountLoginPolicy { return stages; } + public void login(final String audience, final FxAccountLoginDelegate delegate, final SkewHandler skewHandler) { + this.skewHandler = skewHandler; + this.login(audience, delegate); + } + /** * Do as much of a Firefox Account login dance as possible. *

@@ -275,6 +287,10 @@ public class FxAccountLoginPolicy { @Override public void handleFailure(int status, HttpResponse response) { + if (skewHandler != null) { + skewHandler.updateSkew(response, now()); + } + if (status != 401) { delegate.handleError(new FxAccountLoginException(new HTTPFailureException(new SyncStorageResponse(response)))); return; @@ -320,6 +336,10 @@ public class FxAccountLoginPolicy { @Override public void handleFailure(int status, HttpResponse response) { + if (skewHandler != null) { + skewHandler.updateSkew(response, now()); + } + if (status != 401) { delegate.handleError(new FxAccountLoginException(new HTTPFailureException(new SyncStorageResponse(response)))); return; @@ -379,6 +399,10 @@ public class FxAccountLoginPolicy { @Override public void handleFailure(int status, HttpResponse response) { + if (skewHandler != null) { + skewHandler.updateSkew(response, now()); + } + if (status != 401) { delegate.handleError(new FxAccountLoginException(new HTTPFailureException(new SyncStorageResponse(response)))); return; @@ -426,6 +450,10 @@ public class FxAccountLoginPolicy { @Override public void handleFailure(int status, HttpResponse response) { + if (skewHandler != null) { + skewHandler.updateSkew(response, now()); + } + if (status != 401) { delegate.handleError(new FxAccountLoginException(new HTTPFailureException(new SyncStorageResponse(response)))); return; @@ -473,6 +501,10 @@ public class FxAccountLoginPolicy { @Override public void handleFailure(int status, HttpResponse response) { + if (skewHandler != null) { + skewHandler.updateSkew(response, now()); + } + if (status != 401) { delegate.handleError(new FxAccountLoginException(new HTTPFailureException(new SyncStorageResponse(response)))); return; diff --git a/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java b/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java index b806c1e36f4e..c1397e71822e 100644 --- a/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java +++ b/mobile/android/base/fxa/sync/FxAccountSyncAdapter.java @@ -11,6 +11,7 @@ import java.util.concurrent.Executors; import org.mozilla.gecko.background.common.log.Logger; import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.background.fxa.SkewHandler; import org.mozilla.gecko.browserid.verifier.BrowserIDRemoteVerifierClient; import org.mozilla.gecko.browserid.verifier.BrowserIDVerifierDelegate; import org.mozilla.gecko.fxa.FxAccountConstants; @@ -188,8 +189,18 @@ public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter { FxAccountGlobalSession globalSession = null; try { ClientsDataDelegate clientsDataDelegate = new SharedPreferencesClientsDataDelegate(sharedPrefs); - final KeyBundle syncKeyBundle = FxAccountUtils.generateSyncKeyBundle(fxAccount.getKb()); // TODO Document this choice for deriving from kB. - AuthHeaderProvider authHeaderProvider = new HawkAuthHeaderProvider(token.id, token.key.getBytes("UTF-8"), false); + + // TODO Document this choice for deriving from kB. + final KeyBundle syncKeyBundle = FxAccountUtils.generateSyncKeyBundle(fxAccount.getKb()); + + // We compute skew over time using SkewHandler. This yields an unchanging + // skew adjustment that the HawkAuthHeaderProvider uses to adjust its + // timestamps. Eventually we might want this to adapt within the scope of a + // global session. + final SkewHandler tokenServerSkewHandler = SkewHandler.getSkewHandlerFromEndpointString(token.endpoint); + final long tokenServerSkew = tokenServerSkewHandler.getSkewInSeconds(); + AuthHeaderProvider authHeaderProvider = new HawkAuthHeaderProvider(token.id, token.key.getBytes("UTF-8"), false, tokenServerSkew); + globalSession = new FxAccountGlobalSession(token.endpoint, token.uid, authHeaderProvider, FxAccountConstants.PREFS_PATH, syncKeyBundle, callback, getContext(), extras, clientsDataDelegate); globalSession.start(); } catch (Exception e) { diff --git a/mobile/android/base/sync/net/BaseResource.java b/mobile/android/base/sync/net/BaseResource.java index 06fa71e852d8..b9942ad7cd9a 100644 --- a/mobile/android/base/sync/net/BaseResource.java +++ b/mobile/android/base/sync/net/BaseResource.java @@ -69,7 +69,7 @@ public class BaseResource implements Resource { private static final String LOG_TAG = "BaseResource"; - protected URI uri; + protected final URI uri; protected BasicHttpContext context; protected DefaultHttpClient client; public ResourceDelegate delegate; @@ -101,6 +101,7 @@ public class BaseResource implements Resource { this.uri = new URI(uri.getScheme(), uri.getUserInfo(), ANDROID_LOOPBACK_IP, uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment()); } catch (URISyntaxException e) { Logger.error(LOG_TAG, "Got error rewriting URI for Android emulator.", e); + throw new IllegalArgumentException("Invalid URI", e); } } else { this.uri = uri; @@ -121,10 +122,21 @@ public class BaseResource implements Resource { httpResponseObserver = new WeakReference(newHttpResponseObserver); } + @Override public URI getURI() { return this.uri; } + @Override + public String getURIString() { + return this.uri.toString(); + } + + @Override + public String getHostname() { + return this.getURI().getHost(); + } + /** * This shuts up HttpClient, which will otherwise debug log about there * being no auth cache in the context. diff --git a/mobile/android/base/sync/net/BrowserIDAuthHeaderProvider.java b/mobile/android/base/sync/net/BrowserIDAuthHeaderProvider.java index a53a58cdcb21..c5446e29bd16 100644 --- a/mobile/android/base/sync/net/BrowserIDAuthHeaderProvider.java +++ b/mobile/android/base/sync/net/BrowserIDAuthHeaderProvider.java @@ -12,7 +12,7 @@ import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; /** * An AuthHeaderProvider that returns an Authorization header for - * Browser-ID assertions in the format expected by a Mozilla Services Token + * BrowserID assertions in the format expected by a Mozilla Services Token * Server. *

* See http://docs.services.mozilla.com/token/apis.html. diff --git a/mobile/android/base/sync/net/HawkAuthHeaderProvider.java b/mobile/android/base/sync/net/HawkAuthHeaderProvider.java index cb5ba2e26063..a3fd2c445c83 100644 --- a/mobile/android/base/sync/net/HawkAuthHeaderProvider.java +++ b/mobile/android/base/sync/net/HawkAuthHeaderProvider.java @@ -48,6 +48,7 @@ public class HawkAuthHeaderProvider implements AuthHeaderProvider { protected final String id; protected final byte[] key; protected final boolean includePayloadHash; + protected final long skewSeconds; /** * Create a Hawk Authorization header provider. @@ -63,8 +64,12 @@ public class HawkAuthHeaderProvider implements AuthHeaderProvider { * @param includePayloadHash * true if message integrity hash should be included in signed * request header. See https://github.com/hueniverse/hawk#payload-validation. + * + * @param skewSeconds + * a number of seconds by which to skew the current time when + * computing a header. */ - public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash) { + public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash, long skewSeconds) { if (id == null) { throw new IllegalArgumentException("id must not be null"); } @@ -74,11 +79,33 @@ public class HawkAuthHeaderProvider implements AuthHeaderProvider { this.id = id; this.key = key; this.includePayloadHash = includePayloadHash; + this.skewSeconds = skewSeconds; + } + + public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash) { + this(id, key, includePayloadHash, 0L); + } + + + /** + * @return the current time in milliseconds. + */ + @SuppressWarnings("static-method") + protected long now() { + return System.currentTimeMillis(); + } + + /** + * @return the current time in seconds, adjusted for skew. This should + * approximate the server's timestamp. + */ + protected long getTimestampSeconds() { + return (now() / 1000) + skewSeconds; } @Override public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException { - long timestamp = System.currentTimeMillis() / 1000; + long timestamp = getTimestampSeconds(); String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES)); String extra = ""; diff --git a/mobile/android/base/sync/net/Resource.java b/mobile/android/base/sync/net/Resource.java index 054d87f19eb0..f4cae7dad46f 100644 --- a/mobile/android/base/sync/net/Resource.java +++ b/mobile/android/base/sync/net/Resource.java @@ -4,9 +4,14 @@ package org.mozilla.gecko.sync.net; +import java.net.URI; + import ch.boye.httpclientandroidlib.HttpEntity; public interface Resource { + public abstract URI getURI(); + public abstract String getURIString(); + public abstract String getHostname(); public abstract void get(); public abstract void delete(); public abstract void post(HttpEntity body); diff --git a/mobile/android/base/sync/net/SyncStorageRequest.java b/mobile/android/base/sync/net/SyncStorageRequest.java index 72ea5e24d635..29db532275c1 100644 --- a/mobile/android/base/sync/net/SyncStorageRequest.java +++ b/mobile/android/base/sync/net/SyncStorageRequest.java @@ -78,6 +78,21 @@ public class SyncStorageRequest implements Resource { this.resource.delegate = this.resourceDelegate; } + @Override + public URI getURI() { + return this.resource.getURI(); + } + + @Override + public String getURIString() { + return this.resource.getURIString(); + } + + @Override + public String getHostname() { + return this.resource.getHostname(); + } + /** * A ResourceDelegate that mediates between Resource-level notifications and the SyncStorageRequest. */