1 | // Copyright 2012 Joyent, Inc. All rights reserved.
|
---|
2 |
|
---|
3 | var assert = require('assert-plus');
|
---|
4 | var crypto = require('crypto');
|
---|
5 | var http = require('http');
|
---|
6 | var util = require('util');
|
---|
7 | var sshpk = require('sshpk');
|
---|
8 | var jsprim = require('jsprim');
|
---|
9 | var utils = require('./utils');
|
---|
10 |
|
---|
11 | var sprintf = require('util').format;
|
---|
12 |
|
---|
13 | var HASH_ALGOS = utils.HASH_ALGOS;
|
---|
14 | var PK_ALGOS = utils.PK_ALGOS;
|
---|
15 | var InvalidAlgorithmError = utils.InvalidAlgorithmError;
|
---|
16 | var HttpSignatureError = utils.HttpSignatureError;
|
---|
17 | var validateAlgorithm = utils.validateAlgorithm;
|
---|
18 |
|
---|
19 | ///--- Globals
|
---|
20 |
|
---|
21 | var AUTHZ_FMT =
|
---|
22 | 'Signature keyId="%s",algorithm="%s",headers="%s",signature="%s"';
|
---|
23 |
|
---|
24 | ///--- Specific Errors
|
---|
25 |
|
---|
26 | function MissingHeaderError(message) {
|
---|
27 | HttpSignatureError.call(this, message, MissingHeaderError);
|
---|
28 | }
|
---|
29 | util.inherits(MissingHeaderError, HttpSignatureError);
|
---|
30 |
|
---|
31 | function StrictParsingError(message) {
|
---|
32 | HttpSignatureError.call(this, message, StrictParsingError);
|
---|
33 | }
|
---|
34 | util.inherits(StrictParsingError, HttpSignatureError);
|
---|
35 |
|
---|
36 | /* See createSigner() */
|
---|
37 | function RequestSigner(options) {
|
---|
38 | assert.object(options, 'options');
|
---|
39 |
|
---|
40 | var alg = [];
|
---|
41 | if (options.algorithm !== undefined) {
|
---|
42 | assert.string(options.algorithm, 'options.algorithm');
|
---|
43 | alg = validateAlgorithm(options.algorithm);
|
---|
44 | }
|
---|
45 | this.rs_alg = alg;
|
---|
46 |
|
---|
47 | /*
|
---|
48 | * RequestSigners come in two varieties: ones with an rs_signFunc, and ones
|
---|
49 | * with an rs_signer.
|
---|
50 | *
|
---|
51 | * rs_signFunc-based RequestSigners have to build up their entire signing
|
---|
52 | * string within the rs_lines array and give it to rs_signFunc as a single
|
---|
53 | * concat'd blob. rs_signer-based RequestSigners can add a line at a time to
|
---|
54 | * their signing state by using rs_signer.update(), thus only needing to
|
---|
55 | * buffer the hash function state and one line at a time.
|
---|
56 | */
|
---|
57 | if (options.sign !== undefined) {
|
---|
58 | assert.func(options.sign, 'options.sign');
|
---|
59 | this.rs_signFunc = options.sign;
|
---|
60 |
|
---|
61 | } else if (alg[0] === 'hmac' && options.key !== undefined) {
|
---|
62 | assert.string(options.keyId, 'options.keyId');
|
---|
63 | this.rs_keyId = options.keyId;
|
---|
64 |
|
---|
65 | if (typeof (options.key) !== 'string' && !Buffer.isBuffer(options.key))
|
---|
66 | throw (new TypeError('options.key for HMAC must be a string or Buffer'));
|
---|
67 |
|
---|
68 | /*
|
---|
69 | * Make an rs_signer for HMACs, not a rs_signFunc -- HMACs digest their
|
---|
70 | * data in chunks rather than requiring it all to be given in one go
|
---|
71 | * at the end, so they are more similar to signers than signFuncs.
|
---|
72 | */
|
---|
73 | this.rs_signer = crypto.createHmac(alg[1].toUpperCase(), options.key);
|
---|
74 | this.rs_signer.sign = function () {
|
---|
75 | var digest = this.digest('base64');
|
---|
76 | return ({
|
---|
77 | hashAlgorithm: alg[1],
|
---|
78 | toString: function () { return (digest); }
|
---|
79 | });
|
---|
80 | };
|
---|
81 |
|
---|
82 | } else if (options.key !== undefined) {
|
---|
83 | var key = options.key;
|
---|
84 | if (typeof (key) === 'string' || Buffer.isBuffer(key))
|
---|
85 | key = sshpk.parsePrivateKey(key);
|
---|
86 |
|
---|
87 | assert.ok(sshpk.PrivateKey.isPrivateKey(key, [1, 2]),
|
---|
88 | 'options.key must be a sshpk.PrivateKey');
|
---|
89 | this.rs_key = key;
|
---|
90 |
|
---|
91 | assert.string(options.keyId, 'options.keyId');
|
---|
92 | this.rs_keyId = options.keyId;
|
---|
93 |
|
---|
94 | if (!PK_ALGOS[key.type]) {
|
---|
95 | throw (new InvalidAlgorithmError(key.type.toUpperCase() + ' type ' +
|
---|
96 | 'keys are not supported'));
|
---|
97 | }
|
---|
98 |
|
---|
99 | if (alg[0] !== undefined && key.type !== alg[0]) {
|
---|
100 | throw (new InvalidAlgorithmError('options.key must be a ' +
|
---|
101 | alg[0].toUpperCase() + ' key, was given a ' +
|
---|
102 | key.type.toUpperCase() + ' key instead'));
|
---|
103 | }
|
---|
104 |
|
---|
105 | this.rs_signer = key.createSign(alg[1]);
|
---|
106 |
|
---|
107 | } else {
|
---|
108 | throw (new TypeError('options.sign (func) or options.key is required'));
|
---|
109 | }
|
---|
110 |
|
---|
111 | this.rs_headers = [];
|
---|
112 | this.rs_lines = [];
|
---|
113 | }
|
---|
114 |
|
---|
115 | /**
|
---|
116 | * Adds a header to be signed, with its value, into this signer.
|
---|
117 | *
|
---|
118 | * @param {String} header
|
---|
119 | * @param {String} value
|
---|
120 | * @return {String} value written
|
---|
121 | */
|
---|
122 | RequestSigner.prototype.writeHeader = function (header, value) {
|
---|
123 | assert.string(header, 'header');
|
---|
124 | header = header.toLowerCase();
|
---|
125 | assert.string(value, 'value');
|
---|
126 |
|
---|
127 | this.rs_headers.push(header);
|
---|
128 |
|
---|
129 | if (this.rs_signFunc) {
|
---|
130 | this.rs_lines.push(header + ': ' + value);
|
---|
131 |
|
---|
132 | } else {
|
---|
133 | var line = header + ': ' + value;
|
---|
134 | if (this.rs_headers.length > 0)
|
---|
135 | line = '\n' + line;
|
---|
136 | this.rs_signer.update(line);
|
---|
137 | }
|
---|
138 |
|
---|
139 | return (value);
|
---|
140 | };
|
---|
141 |
|
---|
142 | /**
|
---|
143 | * Adds a default Date header, returning its value.
|
---|
144 | *
|
---|
145 | * @return {String}
|
---|
146 | */
|
---|
147 | RequestSigner.prototype.writeDateHeader = function () {
|
---|
148 | return (this.writeHeader('date', jsprim.rfc1123(new Date())));
|
---|
149 | };
|
---|
150 |
|
---|
151 | /**
|
---|
152 | * Adds the request target line to be signed.
|
---|
153 | *
|
---|
154 | * @param {String} method, HTTP method (e.g. 'get', 'post', 'put')
|
---|
155 | * @param {String} path
|
---|
156 | */
|
---|
157 | RequestSigner.prototype.writeTarget = function (method, path) {
|
---|
158 | assert.string(method, 'method');
|
---|
159 | assert.string(path, 'path');
|
---|
160 | method = method.toLowerCase();
|
---|
161 | this.writeHeader('(request-target)', method + ' ' + path);
|
---|
162 | };
|
---|
163 |
|
---|
164 | /**
|
---|
165 | * Calculate the value for the Authorization header on this request
|
---|
166 | * asynchronously.
|
---|
167 | *
|
---|
168 | * @param {Func} callback (err, authz)
|
---|
169 | */
|
---|
170 | RequestSigner.prototype.sign = function (cb) {
|
---|
171 | assert.func(cb, 'callback');
|
---|
172 |
|
---|
173 | if (this.rs_headers.length < 1)
|
---|
174 | throw (new Error('At least one header must be signed'));
|
---|
175 |
|
---|
176 | var alg, authz;
|
---|
177 | if (this.rs_signFunc) {
|
---|
178 | var data = this.rs_lines.join('\n');
|
---|
179 | var self = this;
|
---|
180 | this.rs_signFunc(data, function (err, sig) {
|
---|
181 | if (err) {
|
---|
182 | cb(err);
|
---|
183 | return;
|
---|
184 | }
|
---|
185 | try {
|
---|
186 | assert.object(sig, 'signature');
|
---|
187 | assert.string(sig.keyId, 'signature.keyId');
|
---|
188 | assert.string(sig.algorithm, 'signature.algorithm');
|
---|
189 | assert.string(sig.signature, 'signature.signature');
|
---|
190 | alg = validateAlgorithm(sig.algorithm);
|
---|
191 |
|
---|
192 | authz = sprintf(AUTHZ_FMT,
|
---|
193 | sig.keyId,
|
---|
194 | sig.algorithm,
|
---|
195 | self.rs_headers.join(' '),
|
---|
196 | sig.signature);
|
---|
197 | } catch (e) {
|
---|
198 | cb(e);
|
---|
199 | return;
|
---|
200 | }
|
---|
201 | cb(null, authz);
|
---|
202 | });
|
---|
203 |
|
---|
204 | } else {
|
---|
205 | try {
|
---|
206 | var sigObj = this.rs_signer.sign();
|
---|
207 | } catch (e) {
|
---|
208 | cb(e);
|
---|
209 | return;
|
---|
210 | }
|
---|
211 | alg = (this.rs_alg[0] || this.rs_key.type) + '-' + sigObj.hashAlgorithm;
|
---|
212 | var signature = sigObj.toString();
|
---|
213 | authz = sprintf(AUTHZ_FMT,
|
---|
214 | this.rs_keyId,
|
---|
215 | alg,
|
---|
216 | this.rs_headers.join(' '),
|
---|
217 | signature);
|
---|
218 | cb(null, authz);
|
---|
219 | }
|
---|
220 | };
|
---|
221 |
|
---|
222 | ///--- Exported API
|
---|
223 |
|
---|
224 | module.exports = {
|
---|
225 | /**
|
---|
226 | * Identifies whether a given object is a request signer or not.
|
---|
227 | *
|
---|
228 | * @param {Object} object, the object to identify
|
---|
229 | * @returns {Boolean}
|
---|
230 | */
|
---|
231 | isSigner: function (obj) {
|
---|
232 | if (typeof (obj) === 'object' && obj instanceof RequestSigner)
|
---|
233 | return (true);
|
---|
234 | return (false);
|
---|
235 | },
|
---|
236 |
|
---|
237 | /**
|
---|
238 | * Creates a request signer, used to asynchronously build a signature
|
---|
239 | * for a request (does not have to be an http.ClientRequest).
|
---|
240 | *
|
---|
241 | * @param {Object} options, either:
|
---|
242 | * - {String} keyId
|
---|
243 | * - {String|Buffer} key
|
---|
244 | * - {String} algorithm (optional, required for HMAC)
|
---|
245 | * or:
|
---|
246 | * - {Func} sign (data, cb)
|
---|
247 | * @return {RequestSigner}
|
---|
248 | */
|
---|
249 | createSigner: function createSigner(options) {
|
---|
250 | return (new RequestSigner(options));
|
---|
251 | },
|
---|
252 |
|
---|
253 | /**
|
---|
254 | * Adds an 'Authorization' header to an http.ClientRequest object.
|
---|
255 | *
|
---|
256 | * Note that this API will add a Date header if it's not already set. Any
|
---|
257 | * other headers in the options.headers array MUST be present, or this
|
---|
258 | * will throw.
|
---|
259 | *
|
---|
260 | * You shouldn't need to check the return type; it's just there if you want
|
---|
261 | * to be pedantic.
|
---|
262 | *
|
---|
263 | * The optional flag indicates whether parsing should use strict enforcement
|
---|
264 | * of the version draft-cavage-http-signatures-04 of the spec or beyond.
|
---|
265 | * The default is to be loose and support
|
---|
266 | * older versions for compatibility.
|
---|
267 | *
|
---|
268 | * @param {Object} request an instance of http.ClientRequest.
|
---|
269 | * @param {Object} options signing parameters object:
|
---|
270 | * - {String} keyId required.
|
---|
271 | * - {String} key required (either a PEM or HMAC key).
|
---|
272 | * - {Array} headers optional; defaults to ['date'].
|
---|
273 | * - {String} algorithm optional (unless key is HMAC);
|
---|
274 | * default is the same as the sshpk default
|
---|
275 | * signing algorithm for the type of key given
|
---|
276 | * - {String} httpVersion optional; defaults to '1.1'.
|
---|
277 | * - {Boolean} strict optional; defaults to 'false'.
|
---|
278 | * @return {Boolean} true if Authorization (and optionally Date) were added.
|
---|
279 | * @throws {TypeError} on bad parameter types (input).
|
---|
280 | * @throws {InvalidAlgorithmError} if algorithm was bad or incompatible with
|
---|
281 | * the given key.
|
---|
282 | * @throws {sshpk.KeyParseError} if key was bad.
|
---|
283 | * @throws {MissingHeaderError} if a header to be signed was specified but
|
---|
284 | * was not present.
|
---|
285 | */
|
---|
286 | signRequest: function signRequest(request, options) {
|
---|
287 | assert.object(request, 'request');
|
---|
288 | assert.object(options, 'options');
|
---|
289 | assert.optionalString(options.algorithm, 'options.algorithm');
|
---|
290 | assert.string(options.keyId, 'options.keyId');
|
---|
291 | assert.optionalArrayOfString(options.headers, 'options.headers');
|
---|
292 | assert.optionalString(options.httpVersion, 'options.httpVersion');
|
---|
293 |
|
---|
294 | if (!request.getHeader('Date'))
|
---|
295 | request.setHeader('Date', jsprim.rfc1123(new Date()));
|
---|
296 | if (!options.headers)
|
---|
297 | options.headers = ['date'];
|
---|
298 | if (!options.httpVersion)
|
---|
299 | options.httpVersion = '1.1';
|
---|
300 |
|
---|
301 | var alg = [];
|
---|
302 | if (options.algorithm) {
|
---|
303 | options.algorithm = options.algorithm.toLowerCase();
|
---|
304 | alg = validateAlgorithm(options.algorithm);
|
---|
305 | }
|
---|
306 |
|
---|
307 | var i;
|
---|
308 | var stringToSign = '';
|
---|
309 | for (i = 0; i < options.headers.length; i++) {
|
---|
310 | if (typeof (options.headers[i]) !== 'string')
|
---|
311 | throw new TypeError('options.headers must be an array of Strings');
|
---|
312 |
|
---|
313 | var h = options.headers[i].toLowerCase();
|
---|
314 |
|
---|
315 | if (h === 'request-line') {
|
---|
316 | if (!options.strict) {
|
---|
317 | /**
|
---|
318 | * We allow headers from the older spec drafts if strict parsing isn't
|
---|
319 | * specified in options.
|
---|
320 | */
|
---|
321 | stringToSign +=
|
---|
322 | request.method + ' ' + request.path + ' HTTP/' +
|
---|
323 | options.httpVersion;
|
---|
324 | } else {
|
---|
325 | /* Strict parsing doesn't allow older draft headers. */
|
---|
326 | throw (new StrictParsingError('request-line is not a valid header ' +
|
---|
327 | 'with strict parsing enabled.'));
|
---|
328 | }
|
---|
329 | } else if (h === '(request-target)') {
|
---|
330 | stringToSign +=
|
---|
331 | '(request-target): ' + request.method.toLowerCase() + ' ' +
|
---|
332 | request.path;
|
---|
333 | } else {
|
---|
334 | var value = request.getHeader(h);
|
---|
335 | if (value === undefined || value === '') {
|
---|
336 | throw new MissingHeaderError(h + ' was not in the request');
|
---|
337 | }
|
---|
338 | stringToSign += h + ': ' + value;
|
---|
339 | }
|
---|
340 |
|
---|
341 | if ((i + 1) < options.headers.length)
|
---|
342 | stringToSign += '\n';
|
---|
343 | }
|
---|
344 |
|
---|
345 | /* This is just for unit tests. */
|
---|
346 | if (request.hasOwnProperty('_stringToSign')) {
|
---|
347 | request._stringToSign = stringToSign;
|
---|
348 | }
|
---|
349 |
|
---|
350 | var signature;
|
---|
351 | if (alg[0] === 'hmac') {
|
---|
352 | if (typeof (options.key) !== 'string' && !Buffer.isBuffer(options.key))
|
---|
353 | throw (new TypeError('options.key must be a string or Buffer'));
|
---|
354 |
|
---|
355 | var hmac = crypto.createHmac(alg[1].toUpperCase(), options.key);
|
---|
356 | hmac.update(stringToSign);
|
---|
357 | signature = hmac.digest('base64');
|
---|
358 |
|
---|
359 | } else {
|
---|
360 | var key = options.key;
|
---|
361 | if (typeof (key) === 'string' || Buffer.isBuffer(key))
|
---|
362 | key = sshpk.parsePrivateKey(options.key);
|
---|
363 |
|
---|
364 | assert.ok(sshpk.PrivateKey.isPrivateKey(key, [1, 2]),
|
---|
365 | 'options.key must be a sshpk.PrivateKey');
|
---|
366 |
|
---|
367 | if (!PK_ALGOS[key.type]) {
|
---|
368 | throw (new InvalidAlgorithmError(key.type.toUpperCase() + ' type ' +
|
---|
369 | 'keys are not supported'));
|
---|
370 | }
|
---|
371 |
|
---|
372 | if (alg[0] !== undefined && key.type !== alg[0]) {
|
---|
373 | throw (new InvalidAlgorithmError('options.key must be a ' +
|
---|
374 | alg[0].toUpperCase() + ' key, was given a ' +
|
---|
375 | key.type.toUpperCase() + ' key instead'));
|
---|
376 | }
|
---|
377 |
|
---|
378 | var signer = key.createSign(alg[1]);
|
---|
379 | signer.update(stringToSign);
|
---|
380 | var sigObj = signer.sign();
|
---|
381 | if (!HASH_ALGOS[sigObj.hashAlgorithm]) {
|
---|
382 | throw (new InvalidAlgorithmError(sigObj.hashAlgorithm.toUpperCase() +
|
---|
383 | ' is not a supported hash algorithm'));
|
---|
384 | }
|
---|
385 | options.algorithm = key.type + '-' + sigObj.hashAlgorithm;
|
---|
386 | signature = sigObj.toString();
|
---|
387 | assert.notStrictEqual(signature, '', 'empty signature produced');
|
---|
388 | }
|
---|
389 |
|
---|
390 | var authzHeaderName = options.authorizationHeaderName || 'Authorization';
|
---|
391 |
|
---|
392 | request.setHeader(authzHeaderName, sprintf(AUTHZ_FMT,
|
---|
393 | options.keyId,
|
---|
394 | options.algorithm,
|
---|
395 | options.headers.join(' '),
|
---|
396 | signature));
|
---|
397 |
|
---|
398 | return true;
|
---|
399 | }
|
---|
400 |
|
---|
401 | };
|
---|