|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 package org.mozilla.gecko.sync.net; |
|
6 |
|
7 import java.io.IOException; |
|
8 import java.io.InputStream; |
|
9 import java.io.UnsupportedEncodingException; |
|
10 import java.net.URI; |
|
11 import java.security.GeneralSecurityException; |
|
12 import java.security.InvalidKeyException; |
|
13 import java.security.MessageDigest; |
|
14 import java.security.NoSuchAlgorithmException; |
|
15 import java.util.Locale; |
|
16 |
|
17 import javax.crypto.Mac; |
|
18 import javax.crypto.spec.SecretKeySpec; |
|
19 |
|
20 import org.mozilla.apache.commons.codec.binary.Base64; |
|
21 import org.mozilla.gecko.background.common.log.Logger; |
|
22 import org.mozilla.gecko.sync.Utils; |
|
23 |
|
24 import ch.boye.httpclientandroidlib.Header; |
|
25 import ch.boye.httpclientandroidlib.HttpEntity; |
|
26 import ch.boye.httpclientandroidlib.HttpEntityEnclosingRequest; |
|
27 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; |
|
28 import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; |
|
29 import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; |
|
30 import ch.boye.httpclientandroidlib.message.BasicHeader; |
|
31 import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; |
|
32 |
|
33 /** |
|
34 * An <code>AuthHeaderProvider</code> that returns an Authorization header for |
|
35 * Hawk: <a href="https://github.com/hueniverse/hawk">https://github.com/hueniverse/hawk</a>. |
|
36 * |
|
37 * Hawk is an HTTP authentication scheme using a message authentication code |
|
38 * (MAC) algorithm to provide partial HTTP request cryptographic verification. |
|
39 * Hawk is the successor to the HMAC authentication scheme. |
|
40 */ |
|
41 public class HawkAuthHeaderProvider implements AuthHeaderProvider { |
|
42 public static final String LOG_TAG = HawkAuthHeaderProvider.class.getSimpleName(); |
|
43 |
|
44 public static final int HAWK_HEADER_VERSION = 1; |
|
45 |
|
46 protected static final int NONCE_LENGTH_IN_BYTES = 8; |
|
47 protected static final String HMAC_SHA256_ALGORITHM = "hmacSHA256"; |
|
48 |
|
49 protected final String id; |
|
50 protected final byte[] key; |
|
51 protected final boolean includePayloadHash; |
|
52 protected final long skewSeconds; |
|
53 |
|
54 /** |
|
55 * Create a Hawk Authorization header provider. |
|
56 * <p> |
|
57 * Hawk specifies no mechanism by which a client receives an |
|
58 * identifier-and-key pair from the server. |
|
59 * <p> |
|
60 * Hawk requests can include a payload verification hash with requests that |
|
61 * enclose an entity (PATCH, POST, and PUT requests). <b>You should default |
|
62 * to including the payload verification hash<b> unless you have a good reason |
|
63 * not to -- the server can always ignore payload verification hashes provided |
|
64 * by the client. |
|
65 * |
|
66 * @param id |
|
67 * to name requests with. |
|
68 * @param key |
|
69 * to sign request with. |
|
70 * |
|
71 * @param includePayloadHash |
|
72 * true if payload verification hash should be included in signed |
|
73 * request header. See <a href="https://github.com/hueniverse/hawk#payload-validation">https://github.com/hueniverse/hawk#payload-validation</a>. |
|
74 * |
|
75 * @param skewSeconds |
|
76 * a number of seconds by which to skew the current time when |
|
77 * computing a header. |
|
78 */ |
|
79 public HawkAuthHeaderProvider(String id, byte[] key, boolean includePayloadHash, long skewSeconds) { |
|
80 if (id == null) { |
|
81 throw new IllegalArgumentException("id must not be null"); |
|
82 } |
|
83 if (key == null) { |
|
84 throw new IllegalArgumentException("key must not be null"); |
|
85 } |
|
86 this.id = id; |
|
87 this.key = key; |
|
88 this.includePayloadHash = includePayloadHash; |
|
89 this.skewSeconds = skewSeconds; |
|
90 } |
|
91 |
|
92 /** |
|
93 * @return the current time in milliseconds. |
|
94 */ |
|
95 @SuppressWarnings("static-method") |
|
96 protected long now() { |
|
97 return System.currentTimeMillis(); |
|
98 } |
|
99 |
|
100 /** |
|
101 * @return the current time in seconds, adjusted for skew. This should |
|
102 * approximate the server's timestamp. |
|
103 */ |
|
104 protected long getTimestampSeconds() { |
|
105 return (now() / 1000) + skewSeconds; |
|
106 } |
|
107 |
|
108 @Override |
|
109 public Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client) throws GeneralSecurityException { |
|
110 long timestamp = getTimestampSeconds(); |
|
111 String nonce = Base64.encodeBase64String(Utils.generateRandomBytes(NONCE_LENGTH_IN_BYTES)); |
|
112 String extra = ""; |
|
113 |
|
114 try { |
|
115 return getAuthHeader(request, context, client, timestamp, nonce, extra, this.includePayloadHash); |
|
116 } catch (Exception e) { |
|
117 // We lie a little and make every exception a GeneralSecurityException. |
|
118 throw new GeneralSecurityException(e); |
|
119 } |
|
120 } |
|
121 |
|
122 /** |
|
123 * Helper function that generates an HTTP Authorization: Hawk header given |
|
124 * additional Hawk specific data. |
|
125 * |
|
126 * @throws NoSuchAlgorithmException |
|
127 * @throws InvalidKeyException |
|
128 * @throws IOException |
|
129 */ |
|
130 protected Header getAuthHeader(HttpRequestBase request, BasicHttpContext context, DefaultHttpClient client, |
|
131 long timestamp, String nonce, String extra, boolean includePayloadHash) |
|
132 throws InvalidKeyException, NoSuchAlgorithmException, IOException { |
|
133 if (timestamp < 0) { |
|
134 throw new IllegalArgumentException("timestamp must contain only [0-9]."); |
|
135 } |
|
136 |
|
137 if (nonce == null) { |
|
138 throw new IllegalArgumentException("nonce must not be null."); |
|
139 } |
|
140 if (nonce.length() == 0) { |
|
141 throw new IllegalArgumentException("nonce must not be empty."); |
|
142 } |
|
143 |
|
144 String payloadHash = null; |
|
145 if (includePayloadHash) { |
|
146 payloadHash = getPayloadHashString(request); |
|
147 } else { |
|
148 Logger.debug(LOG_TAG, "Configured to not include payload hash for this request."); |
|
149 } |
|
150 |
|
151 String app = null; |
|
152 String dlg = null; |
|
153 String requestString = getRequestString(request, "header", timestamp, nonce, payloadHash, extra, app, dlg); |
|
154 String macString = getSignature(requestString.getBytes("UTF-8"), this.key); |
|
155 |
|
156 StringBuilder sb = new StringBuilder(); |
|
157 sb.append("Hawk id=\""); |
|
158 sb.append(this.id); |
|
159 sb.append("\", "); |
|
160 sb.append("ts=\""); |
|
161 sb.append(timestamp); |
|
162 sb.append("\", "); |
|
163 sb.append("nonce=\""); |
|
164 sb.append(nonce); |
|
165 sb.append("\", "); |
|
166 if (payloadHash != null) { |
|
167 sb.append("hash=\""); |
|
168 sb.append(payloadHash); |
|
169 sb.append("\", "); |
|
170 } |
|
171 if (extra != null && extra.length() > 0) { |
|
172 sb.append("ext=\""); |
|
173 sb.append(escapeExtraHeaderAttribute(extra)); |
|
174 sb.append("\", "); |
|
175 } |
|
176 sb.append("mac=\""); |
|
177 sb.append(macString); |
|
178 sb.append("\""); |
|
179 |
|
180 return new BasicHeader("Authorization", sb.toString()); |
|
181 } |
|
182 |
|
183 /** |
|
184 * Get the payload verification hash for the given request, if possible. |
|
185 * <p> |
|
186 * Returns null if the request does not enclose an entity (is not an HTTP |
|
187 * PATCH, POST, or PUT). Throws if the payload verification hash cannot be |
|
188 * computed. |
|
189 * |
|
190 * @param request |
|
191 * to compute hash for. |
|
192 * @return verification hash, or null if the request does not enclose an entity. |
|
193 * @throws IllegalArgumentException if the request does not enclose a valid non-null entity. |
|
194 * @throws UnsupportedEncodingException |
|
195 * @throws NoSuchAlgorithmException |
|
196 * @throws IOException |
|
197 */ |
|
198 protected static String getPayloadHashString(HttpRequestBase request) |
|
199 throws UnsupportedEncodingException, NoSuchAlgorithmException, IOException, IllegalArgumentException { |
|
200 final boolean shouldComputePayloadHash = request instanceof HttpEntityEnclosingRequest; |
|
201 if (!shouldComputePayloadHash) { |
|
202 Logger.debug(LOG_TAG, "Not computing payload verification hash for non-enclosing request."); |
|
203 return null; |
|
204 } |
|
205 if (!(request instanceof HttpEntityEnclosingRequest)) { |
|
206 throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request without an entity"); |
|
207 } |
|
208 final HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); |
|
209 if (entity == null) { |
|
210 throw new IllegalArgumentException("Cannot compute payload verification hash for enclosing request with a null entity"); |
|
211 } |
|
212 return Base64.encodeBase64String(getPayloadHash(entity)); |
|
213 } |
|
214 |
|
215 /** |
|
216 * Escape the user-provided extra string for the ext="" header attribute. |
|
217 * <p> |
|
218 * Hawk escapes the header ext="" attribute differently than it does the extra |
|
219 * line in the normalized request string. |
|
220 * <p> |
|
221 * See <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/browser.js#L385</a>. |
|
222 * |
|
223 * @param extra to escape. |
|
224 * @return extra escaped for the ext="" header attribute. |
|
225 */ |
|
226 protected static String escapeExtraHeaderAttribute(String extra) { |
|
227 return extra.replaceAll("\\\\", "\\\\").replaceAll("\"", "\\\""); |
|
228 } |
|
229 |
|
230 /** |
|
231 * Escape the user-provided extra string for inserting into the normalized |
|
232 * request string. |
|
233 * <p> |
|
234 * Hawk escapes the header ext="" attribute differently than it does the extra |
|
235 * line in the normalized request string. |
|
236 * <p> |
|
237 * See <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L67</a>. |
|
238 * |
|
239 * @param extra to escape. |
|
240 * @return extra escaped for the normalized request string. |
|
241 */ |
|
242 protected static String escapeExtraString(String extra) { |
|
243 return extra.replaceAll("\\\\", "\\\\").replaceAll("\n", "\\n"); |
|
244 } |
|
245 |
|
246 /** |
|
247 * Return the content type with no parameters (pieces following ;). |
|
248 * |
|
249 * @param contentTypeHeader to interrogate. |
|
250 * @return base content type. |
|
251 */ |
|
252 protected static String getBaseContentType(Header contentTypeHeader) { |
|
253 if (contentTypeHeader == null) { |
|
254 throw new IllegalArgumentException("contentTypeHeader must not be null."); |
|
255 } |
|
256 String contentType = contentTypeHeader.getValue(); |
|
257 if (contentType == null) { |
|
258 throw new IllegalArgumentException("contentTypeHeader value must not be null."); |
|
259 } |
|
260 int index = contentType.indexOf(";"); |
|
261 if (index < 0) { |
|
262 return contentType.trim(); |
|
263 } |
|
264 return contentType.substring(0, index).trim(); |
|
265 } |
|
266 |
|
267 /** |
|
268 * Generate the SHA-256 hash of a normalized Hawk payload generated from an |
|
269 * HTTP entity. |
|
270 * <p> |
|
271 * <b>Warning:</b> the entity <b>must</b> be repeatable. If it is not, this |
|
272 * code throws an <code>IllegalArgumentException</code>. |
|
273 * <p> |
|
274 * This is under-specified; the code here was reverse engineered from the code |
|
275 * at |
|
276 * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L81</a>. |
|
277 * @param entity to normalize and hash. |
|
278 * @return hash. |
|
279 * @throws IllegalArgumentException if entity is not repeatable. |
|
280 */ |
|
281 protected static byte[] getPayloadHash(HttpEntity entity) throws UnsupportedEncodingException, IOException, NoSuchAlgorithmException { |
|
282 if (!entity.isRepeatable()) { |
|
283 throw new IllegalArgumentException("entity must be repeatable"); |
|
284 } |
|
285 final MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
|
286 digest.update(("hawk." + HAWK_HEADER_VERSION + ".payload\n").getBytes("UTF-8")); |
|
287 digest.update(getBaseContentType(entity.getContentType()).getBytes("UTF-8")); |
|
288 digest.update("\n".getBytes("UTF-8")); |
|
289 InputStream stream = entity.getContent(); |
|
290 try { |
|
291 int numRead; |
|
292 byte[] buffer = new byte[4096]; |
|
293 while (-1 != (numRead = stream.read(buffer))) { |
|
294 if (numRead > 0) { |
|
295 digest.update(buffer, 0, numRead); |
|
296 } |
|
297 } |
|
298 digest.update("\n".getBytes("UTF-8")); // Trailing newline is specified by Hawk. |
|
299 return digest.digest(); |
|
300 } finally { |
|
301 stream.close(); |
|
302 } |
|
303 } |
|
304 |
|
305 /** |
|
306 * Generate a normalized Hawk request string. This is under-specified; the |
|
307 * code here was reverse engineered from the code at |
|
308 * <a href="https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55">https://github.com/hueniverse/hawk/blob/871cc597973110900467bd3dfb84a3c892f678fb/lib/crypto.js#L55</a>. |
|
309 * <p> |
|
310 * This method trusts its inputs to be valid. |
|
311 */ |
|
312 protected static String getRequestString(HttpUriRequest request, String type, long timestamp, String nonce, String hash, String extra, String app, String dlg) { |
|
313 String method = request.getMethod().toUpperCase(Locale.US); |
|
314 |
|
315 URI uri = request.getURI(); |
|
316 String host = uri.getHost(); |
|
317 |
|
318 String path = uri.getRawPath(); |
|
319 if (uri.getRawQuery() != null) { |
|
320 path += "?"; |
|
321 path += uri.getRawQuery(); |
|
322 } |
|
323 if (uri.getRawFragment() != null) { |
|
324 path += "#"; |
|
325 path += uri.getRawFragment(); |
|
326 } |
|
327 |
|
328 int port = uri.getPort(); |
|
329 String scheme = uri.getScheme(); |
|
330 if (port != -1) { |
|
331 } else if ("http".equalsIgnoreCase(scheme)) { |
|
332 port = 80; |
|
333 } else if ("https".equalsIgnoreCase(scheme)) { |
|
334 port = 443; |
|
335 } else { |
|
336 throw new IllegalArgumentException("Unsupported URI scheme: " + scheme + "."); |
|
337 } |
|
338 |
|
339 StringBuilder sb = new StringBuilder(); |
|
340 sb.append("hawk."); |
|
341 sb.append(HAWK_HEADER_VERSION); |
|
342 sb.append('.'); |
|
343 sb.append(type); |
|
344 sb.append('\n'); |
|
345 sb.append(timestamp); |
|
346 sb.append('\n'); |
|
347 sb.append(nonce); |
|
348 sb.append('\n'); |
|
349 sb.append(method); |
|
350 sb.append('\n'); |
|
351 sb.append(path); |
|
352 sb.append('\n'); |
|
353 sb.append(host); |
|
354 sb.append('\n'); |
|
355 sb.append(port); |
|
356 sb.append('\n'); |
|
357 if (hash != null) { |
|
358 sb.append(hash); |
|
359 } |
|
360 sb.append("\n"); |
|
361 if (extra != null && extra.length() > 0) { |
|
362 sb.append(escapeExtraString(extra)); |
|
363 } |
|
364 sb.append("\n"); |
|
365 if (app != null) { |
|
366 sb.append(app); |
|
367 sb.append("\n"); |
|
368 if (dlg != null) { |
|
369 sb.append(dlg); |
|
370 } |
|
371 sb.append("\n"); |
|
372 } |
|
373 |
|
374 return sb.toString(); |
|
375 } |
|
376 |
|
377 protected static byte[] hmacSha256(byte[] message, byte[] key) |
|
378 throws NoSuchAlgorithmException, InvalidKeyException { |
|
379 |
|
380 SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM); |
|
381 |
|
382 Mac hasher = Mac.getInstance(HMAC_SHA256_ALGORITHM); |
|
383 hasher.init(keySpec); |
|
384 hasher.update(message); |
|
385 |
|
386 return hasher.doFinal(); |
|
387 } |
|
388 |
|
389 /** |
|
390 * Sign a Hawk request string. |
|
391 * |
|
392 * @param requestString to sign. |
|
393 * @param key as <code>String</code>. |
|
394 * @return signature as base-64 encoded string. |
|
395 * @throws InvalidKeyException |
|
396 * @throws NoSuchAlgorithmException |
|
397 * @throws UnsupportedEncodingException |
|
398 */ |
|
399 protected static String getSignature(byte[] requestString, byte[] key) |
|
400 throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { |
|
401 return Base64.encodeBase64String(hmacSha256(requestString, key)); |
|
402 } |
|
403 } |