1 |
|
2 |
|
3 | var assert = require('assert-plus');
|
4 | var crypto = require('crypto');
|
5 | var util = require('util');
|
6 | var sshpk = require('sshpk');
|
7 | var jsprim = require('jsprim');
|
8 | var utils = require('./utils');
|
9 |
|
10 | var sprintf = require('util').format;
|
11 |
|
12 | var HASH_ALGOS = utils.HASH_ALGOS;
|
13 | var PK_ALGOS = utils.PK_ALGOS;
|
14 | var InvalidAlgorithmError = utils.InvalidAlgorithmError;
|
15 | var HttpSignatureError = utils.HttpSignatureError;
|
16 | var validateAlgorithm = utils.validateAlgorithm;
|
17 |
|
18 |
|
19 |
|
20 | var AUTHZ_PARAMS = [ 'keyId', 'algorithm', 'created', 'expires', 'opaque',
|
21 | 'headers', 'signature' ];
|
22 |
|
23 |
|
24 |
|
25 | function MissingHeaderError(message) {
|
26 | HttpSignatureError.call(this, message, MissingHeaderError);
|
27 | }
|
28 | util.inherits(MissingHeaderError, HttpSignatureError);
|
29 |
|
30 | function StrictParsingError(message) {
|
31 | HttpSignatureError.call(this, message, StrictParsingError);
|
32 | }
|
33 | util.inherits(StrictParsingError, HttpSignatureError);
|
34 |
|
35 | function 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 |
|
59 | function 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 |
|
71 |
|
72 |
|
73 |
|
74 |
|
75 |
|
76 |
|
77 |
|
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 |
|
92 |
|
93 |
|
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 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
147 | RequestSigner.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 |
|
169 |
|
170 |
|
171 |
|
172 | RequestSigner.prototype.writeDateHeader = function () {
|
173 | return (this.writeHeader('date', jsprim.rfc1123(new Date())));
|
174 | };
|
175 |
|
176 |
|
177 |
|
178 |
|
179 |
|
180 |
|
181 |
|
182 | RequestSigner.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 |
|
191 |
|
192 |
|
193 |
|
194 |
|
195 | RequestSigner.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 |
|
250 |
|
251 | module.exports = {
|
252 | |
253 |
|
254 |
|
255 |
|
256 |
|
257 |
|
258 | isSigner: function (obj) {
|
259 | if (typeof (obj) === 'object' && obj instanceof RequestSigner)
|
260 | return (true);
|
261 | return (false);
|
262 | },
|
263 |
|
264 | |
265 |
|
266 |
|
267 |
|
268 |
|
269 |
|
270 |
|
271 |
|
272 |
|
273 |
|
274 |
|
275 |
|
276 |
|
277 | createSigner: function createSigner(options) {
|
278 | return (new RequestSigner(options));
|
279 | },
|
280 |
|
281 | |
282 |
|
283 |
|
284 |
|
285 |
|
286 |
|
287 |
|
288 |
|
289 |
|
290 |
|
291 |
|
292 |
|
293 |
|
294 |
|
295 |
|
296 |
|
297 |
|
298 |
|
299 |
|
300 |
|
301 |
|
302 |
|
303 |
|
304 |
|
305 |
|
306 |
|
307 |
|
308 |
|
309 |
|
310 |
|
311 |
|
312 |
|
313 |
|
314 |
|
315 |
|
316 |
|
317 |
|
318 |
|
319 |
|
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 |
|
395 |
|
396 |
|
397 | stringToSign +=
|
398 | request.method + ' ' + request.path + ' HTTP/' +
|
399 | options.httpVersion;
|
400 | } else {
|
401 |
|
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 |
|
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 | };
|