[6a3a178] | 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 | };
|
---|