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.net;
michael@0:
michael@0: import java.io.IOException;
michael@0: import java.io.InputStream;
michael@0: import java.io.UnsupportedEncodingException;
michael@0: import java.net.URI;
michael@0: import java.security.GeneralSecurityException;
michael@0: import java.security.InvalidKeyException;
michael@0: import java.security.MessageDigest;
michael@0: import java.security.NoSuchAlgorithmException;
michael@0: import java.util.Locale;
michael@0:
michael@0: import javax.crypto.Mac;
michael@0: import javax.crypto.spec.SecretKeySpec;
michael@0:
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.Utils;
michael@0:
michael@0: import ch.boye.httpclientandroidlib.Header;
michael@0: import ch.boye.httpclientandroidlib.HttpEntity;
michael@0: import ch.boye.httpclientandroidlib.HttpEntityEnclosingRequest;
michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
michael@0: import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
michael@0: import ch.boye.httpclientandroidlib.message.BasicHeader;
michael@0: import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
michael@0:
michael@0: /**
michael@0: * An AuthHeaderProvider
that returns an Authorization header for
michael@0: * Hawk: https://github.com/hueniverse/hawk.
michael@0: *
michael@0: * Hawk is an HTTP authentication scheme using a message authentication code
michael@0: * (MAC) algorithm to provide partial HTTP request cryptographic verification.
michael@0: * Hawk is the successor to the HMAC authentication scheme.
michael@0: */
michael@0: public class HawkAuthHeaderProvider implements AuthHeaderProvider {
michael@0: public static final String LOG_TAG = HawkAuthHeaderProvider.class.getSimpleName();
michael@0:
michael@0: public static final int HAWK_HEADER_VERSION = 1;
michael@0:
michael@0: protected static final int NONCE_LENGTH_IN_BYTES = 8;
michael@0: protected static final String HMAC_SHA256_ALGORITHM = "hmacSHA256";
michael@0:
michael@0: protected final String id;
michael@0: protected final byte[] key;
michael@0: protected final boolean includePayloadHash;
michael@0: protected final long skewSeconds;
michael@0:
michael@0: /**
michael@0: * Create a Hawk Authorization header provider.
michael@0: *
michael@0: * Hawk specifies no mechanism by which a client receives an michael@0: * identifier-and-key pair from the server. michael@0: *
michael@0: * Hawk requests can include a payload verification hash with requests that michael@0: * enclose an entity (PATCH, POST, and PUT requests). You should default michael@0: * to including the payload verification hash unless you have a good reason michael@0: * not to -- the server can always ignore payload verification hashes provided michael@0: * by the client. michael@0: * michael@0: * @param id michael@0: * to name requests with. michael@0: * @param key michael@0: * to sign request with. michael@0: * michael@0: * @param includePayloadHash michael@0: * true if payload verification hash should be included in signed michael@0: * request header. See https://github.com/hueniverse/hawk#payload-validation. michael@0: * michael@0: * @param skewSeconds michael@0: * a number of seconds by which to skew the current time when michael@0: * computing a header. michael@0: */ michael@0: public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash, long skewSeconds) { michael@0: if (id == null) { michael@0: throw new IllegalArgumentException("id must not be null"); michael@0: } michael@0: if (key == null) { michael@0: throw new IllegalArgumentException("key must not be null"); michael@0: } michael@0: this.id = id; michael@0: this.key = key; michael@0: this.includePayloadHash = includePayloadHash; michael@0: this.skewSeconds = skewSeconds; michael@0: } michael@0: michael@0: /** michael@0: * @return the current time in milliseconds. michael@0: */ michael@0: @SuppressWarnings("static-method") michael@0: protected long now() { michael@0: return System.currentTimeMillis(); michael@0: } michael@0: michael@0: /** michael@0: * @return the current time in seconds, adjusted for skew. This should michael@0: * approximate the server's timestamp. michael@0: */ michael@0: protected long getTimestampSeconds() { michael@0: return (now() / 1000) + skewSeconds; michael@0: } michael@0: michael@0: @Override michael@0: public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException { michael@0: long timestamp = getTimestampSeconds(); michael@0: String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES)); michael@0: String extra = ""; michael@0: michael@0: try { michael@0: return getAuthHeader(request, context, client, timestamp, nonce, extra, this.includePayloadHash); michael@0: } catch (Exception e) { michael@0: // We lie a little and make every exception a GeneralSecurityException. michael@0: throw new GeneralSecurityException(e); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Helper function that generates an HTTP Authorization: Hawk header given michael@0: * additional Hawk specific data. michael@0: * michael@0: * @throws NoSuchAlgorithmException michael@0: * @throws InvalidKeyException michael@0: * @throws IOException michael@0: */ michael@0: protected Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client, michael@0: long timestamp, String nonce, String extra, boolean includePayloadHash) michael@0: throws InvalidKeyException, NoSuchAlgorithmException, IOException { michael@0: if (timestamp < 0) { michael@0: throw new IllegalArgumentException("timestamp must contain only [0-9]."); michael@0: } michael@0: michael@0: if (nonce == null) { michael@0: throw new IllegalArgumentException("nonce must not be null."); michael@0: } michael@0: if (nonce.length() == 0) { michael@0: throw new IllegalArgumentException("nonce must not be empty."); michael@0: } michael@0: michael@0: String payloadHash = null; michael@0: if (includePayloadHash) { michael@0: payloadHash = getPayloadHashString(request); michael@0: } else { michael@0: Logger.debug(LOG_TAG, "Configured to not include payload hash for this request."); michael@0: } michael@0: michael@0: String app = null; michael@0: String dlg = null; michael@0: String requestString = getRequestString(request, "header", timestamp, nonce, payloadHash, extra, app, dlg); michael@0: String macString = getSignature(requestString.getBytes("UTF-8"), this.key); michael@0: michael@0: StringBuilder sb = new StringBuilder(); michael@0: sb.append("Hawk id=\""); michael@0: sb.append(this.id); michael@0: sb.append("\", "); michael@0: sb.append("ts=\""); michael@0: sb.append(timestamp); michael@0: sb.append("\", "); michael@0: sb.append("nonce=\""); michael@0: sb.append(nonce); michael@0: sb.append("\", "); michael@0: if (payloadHash != null) { michael@0: sb.append("hash=\""); michael@0: sb.append(payloadHash); michael@0: sb.append("\", "); michael@0: } michael@0: if (extra != null && extra.length() > 0) { michael@0: sb.append("ext=\""); michael@0: sb.append(escapeExtraHeaderAttribute(extra)); michael@0: sb.append("\", "); michael@0: } michael@0: sb.append("mac=\""); michael@0: sb.append(macString); michael@0: sb.append("\""); michael@0: michael@0: return new BasicHeader("Authorization", sb.toString()); michael@0: } michael@0: michael@0: /** michael@0: * Get the payload verification hash for the given request, if possible. michael@0: *
michael@0: * Returns null if the request does not enclose an entity (is not an HTTP michael@0: * PATCH, POST, or PUT). Throws if the payload verification hash cannot be michael@0: * computed. michael@0: * michael@0: * @param request michael@0: * to compute hash for. michael@0: * @return verification hash, or null if the request does not enclose an entity. michael@0: * @throws IllegalArgumentException if the request does not enclose a valid non-null entity. michael@0: * @throws UnsupportedEncodingException michael@0: * @throws NoSuchAlgorithmException michael@0: * @throws IOException michael@0: */ michael@0: protected static String getPayloadHashString(HttpRequestBase request) michael@0: throws UnsupportedEncodingException, NoSuchAlgorithmException, IOException, IllegalArgumentException { michael@0: final boolean shouldComputePayloadHash = request instanceof HttpEntityEnclosingRequest; michael@0: if (!shouldComputePayloadHash) { michael@0: Logger.debug(LOG_TAG, "Not computing payload verification hash for non-enclosing request."); michael@0: return null; michael@0: } michael@0: if (!(request instanceof HttpEntityEnclosingRequest)) { michael@0: throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request without an entity"); michael@0: } michael@0: final HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); michael@0: if (entity == null) { michael@0: throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request with a null entity"); michael@0: } michael@0: return Base64.encodeBase64String(getPayloadHash(entity)); michael@0: } michael@0: michael@0: /** michael@0: * Escape the user-provided extra string for the ext="" header attribute. michael@0: *
michael@0: * Hawk escapes the header ext="" attribute differently than it does the extra michael@0: * line in the normalized request string. michael@0: *
michael@0: * See https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385. michael@0: * michael@0: * @param extra to escape. michael@0: * @return extra escaped for the ext="" header attribute. michael@0: */ michael@0: protected static String escapeExtraHeaderAttribute(String extra) { michael@0: return extra.replaceAll("\\\\", "\\\\").replaceAll("\"", "\\\""); michael@0: } michael@0: michael@0: /** michael@0: * Escape the user-provided extra string for inserting into the normalized michael@0: * request string. michael@0: *
michael@0: * Hawk escapes the header ext="" attribute differently than it does the extra michael@0: * line in the normalized request string. michael@0: *
michael@0: * See https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67. michael@0: * michael@0: * @param extra to escape. michael@0: * @return extra escaped for the normalized request string. michael@0: */ michael@0: protected static String escapeExtraString(String extra) { michael@0: return extra.replaceAll("\\\\", "\\\\").replaceAll("\n", "\\n"); michael@0: } michael@0: michael@0: /** michael@0: * Return the content type with no parameters (pieces following ;). michael@0: * michael@0: * @param contentTypeHeader to interrogate. michael@0: * @return base content type. michael@0: */ michael@0: protected static String getBaseContentType(Header contentTypeHeader) { michael@0: if (contentTypeHeader == null) { michael@0: throw new IllegalArgumentException("contentTypeHeader must not be null."); michael@0: } michael@0: String contentType = contentTypeHeader.getValue(); michael@0: if (contentType == null) { michael@0: throw new IllegalArgumentException("contentTypeHeader value must not be null."); michael@0: } michael@0: int index = contentType.indexOf(";"); michael@0: if (index < 0) { michael@0: return contentType.trim(); michael@0: } michael@0: return contentType.substring(0, index).trim(); michael@0: } michael@0: michael@0: /** michael@0: * Generate the SHA-256 hash of a normalized Hawk payload generated from an michael@0: * HTTP entity. michael@0: *
michael@0: * Warning: the entity must be repeatable. If it is not, this
michael@0: * code throws an IllegalArgumentException
.
michael@0: *
michael@0: * This is under-specified; the code here was reverse engineered from the code michael@0: * at michael@0: * https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81. michael@0: * @param entity to normalize and hash. michael@0: * @return hash. michael@0: * @throws IllegalArgumentException if entity is not repeatable. michael@0: */ michael@0: protected static byte[] getPayloadHash(HttpEntity entity) throws UnsupportedEncodingException, IOException, NoSuchAlgorithmException { michael@0: if (!entity.isRepeatable()) { michael@0: throw new IllegalArgumentException("entity must be repeatable"); michael@0: } michael@0: final MessageDigest digest = MessageDigest.getInstance("SHA-256"); michael@0: digest.update(("hawk." + HAWK_HEADER_VERSION + ".payload\n").getBytes("UTF-8")); michael@0: digest.update(getBaseContentType(entity.getContentType()).getBytes("UTF-8")); michael@0: digest.update("\n".getBytes("UTF-8")); michael@0: InputStream stream = entity.getContent(); michael@0: try { michael@0: int numRead; michael@0: byte[] buffer = new byte[4096]; michael@0: while (-1 != (numRead = stream.read(buffer))) { michael@0: if (numRead > 0) { michael@0: digest.update(buffer, 0, numRead); michael@0: } michael@0: } michael@0: digest.update("\n".getBytes("UTF-8")); // Trailing newline is specified by Hawk. michael@0: return digest.digest(); michael@0: } finally { michael@0: stream.close(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Generate a normalized Hawk request string. This is under-specified; the michael@0: * code here was reverse engineered from the code at michael@0: * https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55. michael@0: *
michael@0: * This method trusts its inputs to be valid.
michael@0: */
michael@0: protected static String getRequestString(HttpUriRequest request, String type, long timestamp, String nonce, String hash, String extra, String app, String dlg) {
michael@0: String method = request.getMethod().toUpperCase(Locale.US);
michael@0:
michael@0: URI uri = request.getURI();
michael@0: String host = uri.getHost();
michael@0:
michael@0: String path = uri.getRawPath();
michael@0: if (uri.getRawQuery() != null) {
michael@0: path += "?";
michael@0: path += uri.getRawQuery();
michael@0: }
michael@0: if (uri.getRawFragment() != null) {
michael@0: path += "#";
michael@0: path += uri.getRawFragment();
michael@0: }
michael@0:
michael@0: int port = uri.getPort();
michael@0: String scheme = uri.getScheme();
michael@0: if (port != -1) {
michael@0: } else if ("http".equalsIgnoreCase(scheme)) {
michael@0: port = 80;
michael@0: } else if ("https".equalsIgnoreCase(scheme)) {
michael@0: port = 443;
michael@0: } else {
michael@0: throw new IllegalArgumentException("Unsupported URI scheme: " + scheme + ".");
michael@0: }
michael@0:
michael@0: StringBuilder sb = new StringBuilder();
michael@0: sb.append("hawk.");
michael@0: sb.append(HAWK_HEADER_VERSION);
michael@0: sb.append('.');
michael@0: sb.append(type);
michael@0: sb.append('\n');
michael@0: sb.append(timestamp);
michael@0: sb.append('\n');
michael@0: sb.append(nonce);
michael@0: sb.append('\n');
michael@0: sb.append(method);
michael@0: sb.append('\n');
michael@0: sb.append(path);
michael@0: sb.append('\n');
michael@0: sb.append(host);
michael@0: sb.append('\n');
michael@0: sb.append(port);
michael@0: sb.append('\n');
michael@0: if (hash != null) {
michael@0: sb.append(hash);
michael@0: }
michael@0: sb.append("\n");
michael@0: if (extra != null && extra.length() > 0) {
michael@0: sb.append(escapeExtraString(extra));
michael@0: }
michael@0: sb.append("\n");
michael@0: if (app != null) {
michael@0: sb.append(app);
michael@0: sb.append("\n");
michael@0: if (dlg != null) {
michael@0: sb.append(dlg);
michael@0: }
michael@0: sb.append("\n");
michael@0: }
michael@0:
michael@0: return sb.toString();
michael@0: }
michael@0:
michael@0: protected static byte[] hmacSha256(byte[] message, byte[] key)
michael@0: throws NoSuchAlgorithmException, InvalidKeyException {
michael@0:
michael@0: SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM);
michael@0:
michael@0: Mac hasher = Mac.getInstance(HMAC_SHA256_ALGORITHM);
michael@0: hasher.init(keySpec);
michael@0: hasher.update(message);
michael@0:
michael@0: return hasher.doFinal();
michael@0: }
michael@0:
michael@0: /**
michael@0: * Sign a Hawk request string.
michael@0: *
michael@0: * @param requestString to sign.
michael@0: * @param key as String
.
michael@0: * @return signature as base-64 encoded string.
michael@0: * @throws InvalidKeyException
michael@0: * @throws NoSuchAlgorithmException
michael@0: * @throws UnsupportedEncodingException
michael@0: */
michael@0: protected static String getSignature(byte[] requestString, byte[] key)
michael@0: throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException {
michael@0: return Base64.encodeBase64String(hmacSha256(requestString, key));
michael@0: }
michael@0: }