UNPKG

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