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
213 changes: 160 additions & 53 deletions lib/cam.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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 @@ -315,8 +316,35 @@
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
// [

Check failure on line 324 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Mixed spaces and tabs

Check failure on line 324 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Mixed spaces and tabs

Check failure on line 324 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Mixed spaces and tabs
// '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) {

Check failure on line 330 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Expected space(s) after "for"

Check failure on line 330 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Expected space(s) after "for"

Check failure on line 330 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Expected space(s) after "for"
if (res.rawHeaders[x].toLowerCase() == "www-authenticate") {
wwwAuthenticateArray.push(res.rawHeaders[x+1]);

Check failure on line 332 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Operator '+' must be spaced

Check failure on line 332 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Operator '+' must be spaced

Check failure on line 332 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Operator '+' must be spaced
}
}

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);
}

Check failure on line 343 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Closing curly brace does not appear on the same line as the subsequent block

Check failure on line 343 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Closing curly brace does not appear on the same line as the subsequent block

Check failure on line 343 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Closing curly brace does not appear on the same line as the subsequent block
catch (err) {
callback('Digest authorization failed: ' + err.message, '', statusCode);
return;
}
}
const bufs = [];
let length = 0;
Expand Down Expand Up @@ -399,68 +427,147 @@
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";

Check failure on line 436 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Expected { after 'if' condition

Check failure on line 436 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Expected { after 'if' condition

Check failure on line 436 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Expected { after 'if' condition

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();

Check failure on line 445 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

'algorithm_lower' is not defined

Check failure on line 445 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 445 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

'algorithm_lower' is not defined

Check failure on line 445 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 445 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'algorithm_lower' is not defined

Check failure on line 445 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Identifier 'algorithm_lower' is not in camel case
if (algorithm_lower == "sha-256" || algorithm_lower == "sha256") {

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

'algorithm_lower' is not defined

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (18.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

'algorithm_lower' is not defined

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (16.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Identifier 'algorithm_lower' is not in camel case

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

'algorithm_lower' is not defined

Check failure on line 446 in lib/cam.js

View workflow job for this annotation

GitHub Actions / Test (20.x)

Identifier 'algorithm_lower' is not in camel case
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}"`
};

// 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')}"`;

// 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;
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
Loading