Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions lib/analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @namespace cam
* @description Analytics section for Cam class
* @author Arteco Global S.p.A. <developers@arteco-global.com>
* @licence MIT
*/
module.exports = function(Cam) {

const linerase = require('./utils').linerase;

/**
* Get the whole set of analytics rules supported by the device
* @param {string} options.configurationToken Contains a reference to the AnalyticsConfiguration to use.
* @param {Cam~GetSupportedRules} [callback]
*/
Cam.prototype.getSupportedRules = function(options, callback) {
this._request({
service: 'analytics'
, body: this._envelopeHeader() +
'<GetSupportedRules xmlns="http://www.onvif.org/ver20/analytics/wsdl">' +
'<ConfigurationToken>' + options.configurationToken + '</ConfigurationToken>' +
'</GetSupportedRules>' +
this._envelopeFooter()
}, function(err, data, xml) {
if (!err) {
/**
* Rules supported by the device
*/
this.rules = linerase(data).getSupportedRulesResponse.supportedRules.ruleDescription; // Array of RuleDescription
}
if (callback) {
callback.call(this, err, this.rules, xml);
}
}.bind(this));
};



};
271 changes: 209 additions & 62 deletions lib/cam.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const http = require('http'),
url = require('url'),
util = require('util'),
splitargs = require('splitargs'),
shajs = require('sha.js'),
linerase = require('./utils').linerase,
parseSOAPString = require('./utils').parseSOAPString,
parseString = require('xml2js').parseString,
Expand Down Expand Up @@ -111,12 +112,20 @@ var Cam = function(options, callback) {
this.on('newListener', function(name) {
// if this is the first listener, start pulling subscription
if (name === 'event' && this.listeners(name).length === 0) {
this.emit('info', 'First listener on event <event>. Starting event request (SetImmediate)');
setImmediate(function() {
this._eventRequest();
}.bind(this));
}
}.bind(this));

this.on('removeListener', function(name) {
// if this is the first listener, start pulling subscription
if (name === 'event') {
this.emit('info', 'Warning: removing listener on event <event>');
}
}.bind(this));

if (options.autoconnect !== false) {
setImmediate(function() {
this.connect(callback);
Expand Down Expand Up @@ -315,8 +324,35 @@ Cam.prototype._requestPart2 = function(options, callback) {
if (statusCode === 401 && wwwAuthenticate !== undefined) {
// Re-request with digest auth header
res.destroy();
options.headers.Authorization = _this.digestAuth(wwwAuthenticate, reqOptions);
_this._requestPart2(options, callback);
try {
// The ONVIF Spec allows for multiple WWW-Authenticate headers, one with MD5 and one with SHA256
// The res.headers[] contains the multiple values combine with a Comma between them
// The res.rawHeaders[] contains the actual data as Key/Value items in an array
// Example from HikVision and rawHeaders
// [
// 'Digest qop="auth", realm="IP Camera", nonce="396539323a38326531323232323a2974d610e80a9fd6cab88e14e7592747", stale="FALSE"' <-- note NO ALGORITHM (default is MD5)
// 'Digest qop="auth", realm="IP Camera", nonce="353066323a38326531323232323a2974d610e80a9fd6cab88e14e7592747", algorithm="SHA-256", stale="FALSE"'
// ]

let wwwAuthenticateArray = [];
for(let x = 0; x < res.rawHeaders.length; x = x + 2) {
if (res.rawHeaders[x].toLowerCase() == "www-authenticate") {
wwwAuthenticateArray.push(res.rawHeaders[x+1]);
}
}

const bestResult = _this.digestAuth(wwwAuthenticateArray, reqOptions);
if (bestResult == null) {
callback('Digest authorization failed. Unable to generate a Authorization Header', '', '', statusCode);
return;
}
options.headers.Authorization = bestResult;
return _this._requestPart2(options, callback);
}
catch (err) {
callback('Digest authorization failed: ' + err.message, '', statusCode);
return;
}
}
const bufs = [];
let length = 0;
Expand Down Expand Up @@ -381,16 +417,47 @@ 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);
// use splitargs to handle things like qop="auth,auth-int"
let parts = splitargs(challenge,',', true); // get a list of Key=Value items. Whitespace will be consumed in the RegEx with \s before the RexEx Groups
// split into Key-Value pairs. We can split on '=' as this cannot appear in the Key name, replacing String with an Array in 'parts'
parts = parts.map(part => part.match(/^\s*?([a-zA-Z0-9]+)="?([^"]*)"?\s*?$/).slice(1));
return Object.fromEntries(parts);
Cam.prototype._parseChallenge = function(header) {
// Example headers:
// `Basic realm="461c280452a715fbc60f4696"`
// `Digest realm="foo", nonce="bar"`

if (!header) return {};

// 1. Extract the scheme (Basic, Digest, ...)
const trimmed = header.trim();
const firstSpace = trimmed.indexOf(" ");
let scheme = null;
let challenge = trimmed;

if (firstSpace > 0) {
scheme = trimmed.substring(0, firstSpace);
challenge = trimmed.substring(firstSpace + 1).trim();
}

// 2. Store the scheme for convenience
const result = {};
if (scheme) result.scheme = scheme;

// 3. Split the challenge into comma-separated key/value pairs
const parts = challenge.split(",").map(part => part.trim());

// 4. Robust key="value" matcher
const kvRe = /^([a-zA-Z0-9_-]+)="([^"]*)"$/;

for (const p of parts) {
const match = p.match(kvRe);
if (match) {
const k = match[1];
const v = match[2];
result[k] = v;
}
}

return result;
};


Cam.prototype.updateNC = function() {
this._nc += 1;
if (this._nc > 99999999) {
Expand All @@ -399,68 +466,147 @@ Cam.prototype.updateNC = function() {
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(':'));
Cam.prototype.createHash = function(algorithm) {
// Try NodeJS built in algorithms, with fall back to Javascript SHA 256 and SHA 512 for older copies of NodeJS
// Relies on the fallback library having a .update() and a .digest() function, the same as the NodeJS built in functions

// Sony SRG-XP1 sends qop="auth,auth-int" it means the Server will accept either "auth" or "auth-int". We select "auth"
if (typeof challenge.qop === 'string' && challenge.qop === 'auth,auth-int') {
challenge.qop = 'auth';
}
let isFipsCompliant = crypto.getFips() === 1;
//isFipsCompliant = true; // for testing purposes
if (isFipsCompliant && (algorithm == "MD5" || algorithm == "md5")) throw "Error. MD5 not permitted on FIPS systems";

const ha2 = crypto.createHash('md5');
ha2.update([reqOptions.method, reqOptions.path].join(':'));

let cnonce = null;
let nc = null;
if (typeof challenge.qop === 'string' && challenge.qop === 'auth') {
const cnonceHash = crypto.createHash('md5');
cnonceHash.update(Math.random().toString(36));
cnonce = cnonceHash.digest('hex').substring(0, 8);
nc = this.updateNC();
let hash;
try {
// Try NodeJS built in Crypto and then fall back to JS SHA256 library
hash = crypto.createHash(algorithm);
} catch (err) {
try {
algorithm_lower = algorithm.toLowerCase();
if (algorithm_lower == "sha-256" || algorithm_lower == "sha256") {
hash = shajs('sha256');
}
else if (algorithm_lower == "sha-512" || algorithm_lower == "sha512") {
hash = shajs('sha512');
}
else
{
// We get here when in FIPS mode and MD5 is selected
throw new Error("Cannot use crypto algorithm " + algorithm);
}
} catch (err) {
throw new Error("Cannot use crypto algorithm " + algorithm);
}
}
return hash;
}

Cam.prototype.digestAuth = function(wwwAuthenticateArray, reqOptions) {

// Process each item in the wwwAuthenticateArray
// Most cameras have only 1 item.
// HikVision implementing the new MD5-then-SHA256 have two items

let bestResult = null;
let bestAlgorithm = null;

for (let arrayItem of wwwAuthenticateArray) {
let challenge = this._parseChallenge(arrayItem);

// if 'algorithm' is undefined, the Digest RFC says we default to MD5
const algorithm = (challenge.algorithm === undefined ? 'md5' : challenge.algorithm);

// The NodeJS will accept the algorithm in any case (upper or lower or mixed) and also accepts SHA256 with and without the 'dash'
// so we don't need to sanitze the algorithm string
let ha1;
// If the algorithm is not supported then this.createHash will throw. Eg SHA-256 is not supported on older copies of NodeJS unless using an external library
try {
// Our local createHash will try NodeJS built in Crypto and then fall back to a JS SHA256 library
ha1 = this.createHash(algorithm);
} catch (err) {
// cannot create hash
// console.warn('NodeJS does not support this Hash Algorithm ' + algorithm + " Falling back to Javascript library");
continue;
}
ha1.update([this.username, challenge.realm, this.password].join(':'));

// No qop -> Response = MD5(HA1:nonce:HA2);
// With qop -> Response = MD5(HA1:nonce:nonceCount:cnonce:qop:HA2)
const response = crypto.createHash('md5');
const responseParams = [
ha1.digest('hex'),
challenge.nonce
];
if (cnonce != null) {
responseParams.push(nc);
responseParams.push(cnonce);
responseParams.push(challenge.qop);
}
// Sony SRG-XP1 sends qop="auth,auth-int" it means the Server will accept either "auth" or "auth-int". We select "auth"
// We also need to handle spaces in the string e.g.like "auth, auth-int"
// So we split the QOP and then see if one item is "auth" using a trim to remove whitespace
if (typeof challenge.qop === 'string' && challenge.qop.split(',').some(item => item.trim() == 'auth')) {
challenge.qop = "auth";
}

responseParams.push(ha2.digest('hex'));
response.update(responseParams.join(':'));
const ha2 = this.createHash(algorithm);
ha2.update([reqOptions.method, reqOptions.path].join(':'));

const authParams = {
username: `"${this.username}"`,
realm: `"${challenge.realm}"`,
nonce: `"${challenge.nonce}"`,
uri: `"${reqOptions.path}"`
};
let cnonce = null;
let nc = null;
if (typeof challenge.qop === 'string' && challenge.qop === 'auth') {
const cnonceHash = this.createHash(algorithm);
cnonceHash.update(Math.random().toString(36));
cnonce = cnonceHash.digest('hex').substring(0, 8);
nc = this.updateNC();
}

// RFC says only send qop, nc and cnonce if there was a QOP in the Header
// 'qop' and 'nc' do not have quotes around the Values
if ('qop' in challenge) {
authParams.qop = challenge.qop; // no quotes
authParams.nc = nc; // no quotes
authParams.cnonce = `"${cnonce}"`;
}
// HASH_ALG is usually MD5 but can also be SHA256
// No qop -> Response = HASH_ALG(HA1:nonce:HA2);
// With qop -> Response = HASH_ALG(HA1:nonce:nonceCount:cnonce:qop:HA2)
const response = this.createHash(algorithm);
const responseParams = [
ha1.digest('hex'),
challenge.nonce
];
if (cnonce != null) {
responseParams.push(nc);
responseParams.push(cnonce);
responseParams.push(challenge.qop);
}

authParams.response = `"${response.digest('hex')}"`;
responseParams.push(ha2.digest('hex'));
response.update(responseParams.join(':'));

if (challenge.opaque) {
authParams.opaque = `"${challenge.opaque}"`;
}
const authParams = {
username: `"${this.username}"`,
realm: `"${challenge.realm}"`,
nonce: `"${challenge.nonce}"`,
uri: `"${reqOptions.path}"`
};

// There are RFC non compliances here. Some values do not need to be in quotes for example 'nc'
const result = 'Digest ' + Object.entries(authParams).map(([key, value]) => `${key}=${value}`).join(',');
return result;
// Send back the original algorithm value, if we received one.
if ('algorithm' in challenge) {
authParams.algorithm = challenge.algorithm
}

// RFC says only send qop, nc and cnonce if there was a QOP in the Header
// 'qop' and 'nc' do not have quotes around the Values
if ('qop' in challenge) {
authParams.qop = challenge.qop; // no quotes
authParams.nc = nc; // no quotes
authParams.cnonce = `"${cnonce}"`;
}

authParams.response = `"${response.digest('hex')}"`;

if (challenge.opaque) {
authParams.opaque = `"${challenge.opaque}"`;
}

const result = 'Digest ' + Object.entries(authParams).map(([key, value]) => `${key}=${value}`).join(',');

if (bestResult == null) {
bestResult = result;
bestAlgorithm = algorithm;
}
else {
if (algorithm == "SHA-256" && bestAlgorithm == "MD5") {
// upgrade the bestResult to the stronger algorithm
bestRestult = result;
bestAlgorithm = algorithm;
}
}

}
return bestResult;
};

/**
Expand Down Expand Up @@ -725,7 +871,7 @@ Cam.prototype.getCapabilities = function(callback) {
*/
this.uri = {};
}
['PTZ', 'media', 'imaging', 'events', 'device'].forEach(function(name) {
['PTZ', 'media', 'imaging', 'events', 'device', 'analytics'].forEach(function(name) {
if (this.capabilities[name] && this.capabilities[name].XAddr) {
this.uri[name.toLowerCase()] = this._parseUrl(this.capabilities[name].XAddr);
}
Expand Down Expand Up @@ -1239,3 +1385,4 @@ require('./ptz')(Cam);
require('./imaging')(Cam);
require('./recording')(Cam);
require('./replay')(Cam);
require('./analytics')(Cam);
Loading
Loading