From db11defd2114d1ebffce4e76030a3978b378a81c Mon Sep 17 00:00:00 2001 From: "Andrew D.Laptev" Date: Mon, 28 Oct 2024 18:03:28 +0300 Subject: [PATCH 1/2] feat: Digest Authentication support. You can turn off WS-Security via `useWSSecurity: false` option in the constructor and use only Digest auth --- lib/cam.js | 137 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 22 deletions(-) diff --git a/lib/cam.js b/lib/cam.js index 3357129..a041573 100755 --- a/lib/cam.js +++ b/lib/cam.js @@ -30,8 +30,9 @@ const http = require('http'), /** * @typedef Cam~Options - * @property {boolean} useSecure Set true if `https:`, defaults to false - * @property {object} secureOpts Set options for https like ca, cert, ciphers, rejectUnauthorized, secureOptions, secureProtocol, etc. + * @property {boolean} [useSecure] Set true if `https:`, defaults to false + * @property {object} [secureOpts] Set options for https like ca, cert, ciphers, rejectUnauthorized, secureOptions, secureProtocol, etc. + * @property {boolean} [useWSSecurity] Use WS-Security SOAP headers * @property {string} hostname * @property {string} [username] * @property {string} [password] @@ -90,6 +91,12 @@ var Cam = function(options, callback) { this.path = options.path || '/onvif/device_service'; this.timeout = options.timeout || 120000; this.agent = options.agent || false; + /** + * Use WS-Security SOAP header + * @type {boolean} + */ + this.useWSSecurity = typeof options.useWSSecurity === 'boolean' ? options.useWSSecurity : true; + this._nc = 0; /** * Force using hostname and port from constructor for the services * @type {boolean} @@ -213,6 +220,7 @@ Cam.prototype.connect = function(callback) { * Common camera request * @param {object} options * @param {string} [options.service] Name of service (ptz, media, etc) + * @param {string} [options.action] Name of the action. Not required, but desired * @param {string} options.body SOAP body * @param {string} [options.url] Defines another url to request instead of using the URLs from GetCapabilities/GetServices * @param {number} options.replyTimeout timeout in milliseconds for the reply (after the socket has connected) @@ -255,10 +263,23 @@ Cam.prototype._request = function(options, callback) { } }; +/** + * Common camera request part 2 + * @param {Object} options + * @param {Object} options.headers If some of the headers must present (Digest auth) + * @param {string} options.action Name of the action + * @param {string} [options.service] Name of service (ptz, media, etc) + * @param {string} options.body SOAP body + * @param {string} [options.url] Defines another url to request instead of using the URLs from GetCapabilities/GetServices + * @param {number} options.replyTimeout timeout in milliseconds for the reply (after the socket has connected) + * @param {Cam~RequestCallback} callback response callback + * @private + */ Cam.prototype._requestPart2 = function(options, callback) { - var _this = this; - var callbackExecuted = false; - var reqOptions = options.url || { + const _this = this; + let callbackExecuted = false; + options.headers = options.headers || {}; + let reqOptions = options.url || { hostname: this.hostname, port: this.port, agent: this.agent //Supports things like https://www.npmjs.com/package/proxy-agent which provide SOCKS5 and other connections @@ -267,20 +288,28 @@ Cam.prototype._requestPart2 = function(options, callback) { (this.uri && this.uri[options.service] ? this.uri[options.service].path : options.service) : this.path, timeout: this.timeout }; - reqOptions.headers = { + reqOptions.headers = options.headers; + Object.assign(reqOptions.headers, { 'Content-Type': `application/soap+xml;charset=utf-8;action="${options.action}"`, 'Content-Length': Buffer.byteLength(options.body, 'utf8') //options.body.length chinese will be wrong here , charset: 'utf-8' - }; + }); reqOptions.method = 'POST'; - var httpLib = this.useSecure ? https : http; + const httpLib = this.useSecure ? https : http; reqOptions = this.useSecure ? Object.assign({}, this.secureOpts, reqOptions) : reqOptions; - var req = httpLib.request(reqOptions, function(res) { + const req = httpLib.request(reqOptions, function(res) { + const wwwAuthenticate = res.headers['www-authenticate']; const statusCode = res.statusCode; - var bufs = [], - length = 0; + if (statusCode === 401 && wwwAuthenticate !== undefined) { + // Re-request with digest auth header + res.destroy(); + options.headers.Authorization = _this.digestAuth(wwwAuthenticate, reqOptions); + _this._requestPart2(options, callback); + } + const bufs = []; + let length = 0; res.on('data', function(chunk) { bufs.push(chunk); length += chunk.length; @@ -314,7 +343,7 @@ Cam.prototype._requestPart2 = function(options, callback) { callbackExecuted = true; } callback(new Error('Network timeout')); - req.abort(); + req.destroy(); }); req.on('error', function(err) { @@ -342,6 +371,70 @@ Cam.prototype._requestPart2 = function(options, callback) { req.end(); }; +Cam.prototype._parseChallenge = function(digest) { + const prefix = 'Digest '; + const challenge = digest.substring(digest.indexOf(prefix) + prefix.length); + const parts = challenge.split(',') + .map(part => part.match(/^\s*?([a-zA-Z0-9]+)="?([^"]*)"?\s*?$/).slice(1)); + return Object.fromEntries(parts); +}; + +Cam.prototype.updateNC = function() { + this._nc += 1; + if (this._nc > 99999999) { + this._nc = 1; + } + return String(this._nc).padStart(8, '0'); +}; + +Cam.prototype.digestAuth = function(wwwAuthenticate, reqOptions) { + const challenge = this._parseChallenge(wwwAuthenticate); + const ha1 = crypto.createHash('md5'); + ha1.update([this.username, challenge.realm, this.password].join(':')); + const ha2 = crypto.createHash('md5'); + ha2.update([reqOptions.method, reqOptions.path].join(':')); + + let cnonce = null; + let nc = null; + if (typeof challenge.qop === 'string') { + const cnonceHash = crypto.createHash('md5'); + cnonceHash.update(Math.random().toString(36)); + cnonce = cnonceHash.digest('hex').substring(0, 8); + nc = this.updateNC(); + } + + const response = crypto.createHash('md5'); + const responseParams = [ + ha1.digest('hex'), + challenge.nonce + ]; + if (cnonce) { + responseParams.push(nc); + responseParams.push(cnonce); + } + + responseParams.push(challenge.qop); + responseParams.push(ha2.digest('hex')); + response.update(responseParams.join(':')); + + const authParams = { + username: this.username, + realm: challenge.realm, + nonce: challenge.nonce, + uri: reqOptions.path, + qop: challenge.qop, + response: response.digest('hex'), + }; + if (challenge.opaque) { + authParams.opaque = challenge.opaque; + } + if (cnonce) { + authParams.nc = nc; + authParams.cnonce = cnonce; + } + return 'Digest ' + Object.entries(authParams).map(([key, value]) => `${key}="${value}"`).join(','); +}; + /** * @callback Cam~DateTimeCallback * @property {?Error} error @@ -1056,19 +1149,19 @@ Cam.prototype._passwordDigest = function() { * @private */ Cam.prototype._envelopeHeader = function(openHeader) { - var header = '' + + let header = '' + ''; // Only insert Security if there is a username and password - if (this.username && this.password) { - var req = this._passwordDigest(); + if (this.useWSSecurity && this.username && this.password) { + const req = this._passwordDigest(); header += '' + - '' + - '' + this.username + '' + - '' + req.passdigest + '' + - '' + req.nonce + '' + - '' + req.timestamp + '' + - '' + - ''; + '' + + '' + this.username + '' + + '' + req.passdigest + '' + + '' + req.nonce + '' + + '' + req.timestamp + '' + + '' + + ''; } if (!(openHeader !== undefined && openHeader)) { header += '' + From 62207b76f1671a06c893d4ff2be0e08c9c397845 Mon Sep 17 00:00:00 2001 From: "Andrew D.Laptev" Date: Mon, 28 Oct 2024 18:10:46 +0300 Subject: [PATCH 2/2] chore: update minimal node version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d15a34e..e26fbc2 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ ], "license": "MIT", "engines": { - "node": ">=10.0" + "node": ">=12.0" }, "devDependencies": { "dot": "^1.1.3",