Commit 6846bc3 for jssip.net

commit 6846bc35673da37c2ffd140b23a849f397e2be53
Author: José Luis Millán <jmillan@aliax.net>
Date:   Tue Oct 20 11:48:21 2026 +0200

    TS: move DigestAuthentication (#983)

diff --git a/src/DigestAuthentication.js b/src/DigestAuthentication.js
deleted file mode 100644
index fb90d81..0000000
--- a/src/DigestAuthentication.js
+++ /dev/null
@@ -1,227 +0,0 @@
-const { Logger } = require('./Logger');
-const Utils = require('./Utils');
-
-const logger = new Logger('DigestAuthentication');
-
-module.exports = class DigestAuthentication {
-	constructor(credentials) {
-		this._credentials = credentials;
-		this._cnonce = null;
-		this._nc = 0;
-		this._ncHex = '00000000';
-		this._algorithm = null;
-		this._realm = null;
-		this._nonce = null;
-		this._opaque = null;
-		this._stale = null;
-		this._qop = null;
-		this._method = null;
-		this._uri = null;
-		this._ha1 = null;
-		this._response = null;
-	}
-
-	get(parameter) {
-		switch (parameter) {
-			case 'realm': {
-				return this._realm;
-			}
-
-			case 'ha1': {
-				return this._ha1;
-			}
-
-			default: {
-				logger.warn('get() | cannot get "%s" parameter', parameter);
-
-				return undefined;
-			}
-		}
-	}
-
-	/**
-	 * Performs Digest authentication given a SIP request and the challenge
-	 * received in a response to that request.
-	 * Returns true if auth was successfully generated, false otherwise.
-	 */
-	authenticate(
-		{ method, ruri, body },
-		challenge,
-		cnonce = null /* test interface */
-	) {
-		this._algorithm = challenge.algorithm;
-		this._realm = challenge.realm;
-		this._nonce = challenge.nonce;
-		this._opaque = challenge.opaque;
-		this._stale = challenge.stale;
-
-		if (this._algorithm) {
-			if (this._algorithm !== 'MD5') {
-				logger.warn(
-					'authenticate() | challenge with Digest algorithm different than "MD5", authentication aborted'
-				);
-
-				return false;
-			}
-		} else {
-			this._algorithm = 'MD5';
-		}
-
-		if (!this._nonce) {
-			logger.warn(
-				'authenticate() | challenge without Digest nonce, authentication aborted'
-			);
-
-			return false;
-		}
-
-		if (!this._realm) {
-			logger.warn(
-				'authenticate() | challenge without Digest realm, authentication aborted'
-			);
-
-			return false;
-		}
-
-		// If no plain SIP password is provided.
-		if (!this._credentials.password) {
-			// If ha1 is not provided we cannot authenticate.
-			if (!this._credentials.ha1) {
-				logger.warn(
-					'authenticate() | no plain SIP password nor ha1 provided, authentication aborted'
-				);
-
-				return false;
-			}
-
-			// If the realm does not match the stored realm we cannot authenticate.
-			if (this._credentials.realm !== this._realm) {
-				logger.warn(
-					'authenticate() | no plain SIP password, and stored `realm` does not match the given `realm`, cannot authenticate [stored:"%s", given:"%s"]',
-					this._credentials.realm,
-					this._realm
-				);
-
-				return false;
-			}
-		}
-
-		// 'qop' can contain a list of values (Array). Let's choose just one.
-		if (challenge.qop) {
-			if (challenge.qop.indexOf('auth-int') > -1) {
-				this._qop = 'auth-int';
-			} else if (challenge.qop.indexOf('auth') > -1) {
-				this._qop = 'auth';
-			} else {
-				// Otherwise 'qop' is present but does not contain 'auth' or 'auth-int', so abort here.
-				logger.warn(
-					'authenticate() | challenge without Digest qop different than "auth" or "auth-int", authentication aborted'
-				);
-
-				return false;
-			}
-		} else {
-			this._qop = null;
-		}
-
-		// Fill other attributes.
-
-		this._method = method;
-		this._uri = ruri;
-		this._cnonce = cnonce || Utils.createRandomToken(12);
-		this._nc += 1;
-		const hex = Number(this._nc).toString(16);
-
-		this._ncHex = '00000000'.substr(0, 8 - hex.length) + hex;
-
-		// Nc-value = 8LHEX. Max value = 'FFFFFFFF'.
-		if (this._nc === 4294967296) {
-			this._nc = 1;
-			this._ncHex = '00000001';
-		}
-
-		// Calculate the Digest "response" value.
-
-		// If we have plain SIP password then regenerate ha1.
-		if (this._credentials.password) {
-			// HA1 = MD5(A1) = MD5(username:realm:password).
-			this._ha1 = Utils.calculateMD5(
-				`${this._credentials.username}:${this._realm}:${this._credentials.password}`
-			);
-		}
-		// Otherwise reuse the stored ha1.
-		else {
-			this._ha1 = this._credentials.ha1;
-		}
-
-		let a2;
-		let ha2;
-
-		if (this._qop === 'auth') {
-			// HA2 = MD5(A2) = MD5(method:digestURI).
-			a2 = `${this._method}:${this._uri}`;
-			ha2 = Utils.calculateMD5(a2);
-
-			logger.debug('authenticate() | using qop=auth [a2:"%s"]', a2);
-
-			// Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2).
-			this._response = Utils.calculateMD5(
-				`${this._ha1}:${this._nonce}:${this._ncHex}:${this._cnonce}:auth:${ha2}`
-			);
-		} else if (this._qop === 'auth-int') {
-			// HA2 = MD5(A2) = MD5(method:digestURI:MD5(entityBody)).
-			a2 = `${this._method}:${this._uri}:${Utils.calculateMD5(body ? body : '')}`;
-			ha2 = Utils.calculateMD5(a2);
-
-			logger.debug('authenticate() | using qop=auth-int [a2:"%s"]', a2);
-
-			// Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2).
-			this._response = Utils.calculateMD5(
-				`${this._ha1}:${this._nonce}:${this._ncHex}:${this._cnonce}:auth-int:${ha2}`
-			);
-		} else if (this._qop === null) {
-			// HA2 = MD5(A2) = MD5(method:digestURI).
-			a2 = `${this._method}:${this._uri}`;
-			ha2 = Utils.calculateMD5(a2);
-
-			logger.debug('authenticate() | using qop=null [a2:"%s"]', a2);
-
-			// Response = MD5(HA1:nonce:HA2).
-			this._response = Utils.calculateMD5(`${this._ha1}:${this._nonce}:${ha2}`);
-		}
-
-		logger.debug('authenticate() | response generated');
-
-		return true;
-	}
-
-	/**
-	 * Return the Proxy-Authorization or WWW-Authorization header value.
-	 */
-	toString() {
-		const auth_params = [];
-
-		if (!this._response) {
-			throw new Error(
-				'response field does not exist, cannot generate Authorization header'
-			);
-		}
-
-		auth_params.push(`algorithm=${this._algorithm}`);
-		auth_params.push(`username="${this._credentials.username}"`);
-		auth_params.push(`realm="${this._realm}"`);
-		auth_params.push(`nonce="${this._nonce}"`);
-		auth_params.push(`uri="${this._uri}"`);
-		auth_params.push(`response="${this._response}"`);
-		if (this._opaque) {
-			auth_params.push(`opaque="${this._opaque}"`);
-		}
-		if (this._qop) {
-			auth_params.push(`qop=${this._qop}`);
-			auth_params.push(`cnonce="${this._cnonce}"`);
-			auth_params.push(`nc=${this._ncHex}`);
-		}
-
-		return `Digest ${auth_params.join(', ')}`;
-	}
-};
diff --git a/src/DigestAuthentication.ts b/src/DigestAuthentication.ts
new file mode 100644
index 0000000..a89a09b
--- /dev/null
+++ b/src/DigestAuthentication.ts
@@ -0,0 +1,274 @@
+import { Logger } from './Logger';
+import { calculateMD5, createRandomToken } from './Utils';
+
+const logger = new Logger('DigestAuthentication');
+
+export type DigestCredentials = {
+	username?: string;
+	password?: string;
+	realm?: string;
+	ha1?: string | null;
+};
+
+export type DigestChallenge = {
+	algorithm?: string | null;
+	realm?: string | null;
+	nonce?: string | null;
+	opaque?: string | null;
+	stale?: boolean | null;
+	qop?: string | string[] | null;
+};
+
+export type DigestAuthenticateRequest = {
+	method: string;
+	ruri: string | { toString(): string };
+	body?: string | null;
+};
+
+export class DigestAuthentication {
+	#credentials: DigestCredentials;
+	#cnonce: string | null;
+	#nc: number;
+	#ncHex: string;
+	#algorithm: string | null | undefined;
+	#realm: string | null | undefined;
+	#nonce: string | null | undefined;
+	#opaque: string | null | undefined;
+	// eslint-disable-next-line no-unused-private-class-members
+	#stale: boolean | null | undefined;
+	#qop: 'auth' | 'auth-int' | null;
+	#method: string | null;
+	#uri: string | { toString(): string } | null;
+	#ha1: string | null | undefined;
+	#response: string | null;
+
+	constructor(credentials: DigestCredentials) {
+		this.#credentials = credentials;
+		this.#cnonce = null;
+		this.#nc = 0;
+		this.#ncHex = '00000000';
+		this.#algorithm = null;
+		this.#realm = null;
+		this.#nonce = null;
+		this.#opaque = null;
+		this.#stale = null;
+		this.#qop = null;
+		this.#method = null;
+		this.#uri = null;
+		this.#ha1 = null;
+		this.#response = null;
+	}
+
+	/**
+	 * Get the generated Digest "response" value.
+	 *
+	 * NOTE: Exposed for testing purposes only.
+	 */
+	get response(): string | null {
+		return this.#response;
+	}
+
+	get(parameter: string): string | null | undefined {
+		switch (parameter) {
+			case 'realm': {
+				return this.#realm;
+			}
+
+			case 'ha1': {
+				return this.#ha1;
+			}
+
+			default: {
+				logger.warn('get() | cannot get "%s" parameter', parameter);
+
+				return undefined;
+			}
+		}
+	}
+
+	/**
+	 * Performs Digest authentication given a SIP request and the challenge
+	 * received in a response to that request.
+	 * Returns true if auth was successfully generated, false otherwise.
+	 */
+	authenticate(
+		{ method, ruri, body }: DigestAuthenticateRequest,
+		challenge: DigestChallenge,
+		cnonce: string | null = null /* test interface */
+	): boolean {
+		this.#algorithm = challenge.algorithm;
+		this.#realm = challenge.realm;
+		this.#nonce = challenge.nonce;
+		this.#opaque = challenge.opaque;
+		this.#stale = challenge.stale;
+
+		if (this.#algorithm) {
+			if (this.#algorithm !== 'MD5') {
+				logger.warn(
+					'authenticate() | challenge with Digest algorithm different than "MD5", authentication aborted'
+				);
+
+				return false;
+			}
+		} else {
+			this.#algorithm = 'MD5';
+		}
+
+		if (!this.#nonce) {
+			logger.warn(
+				'authenticate() | challenge without Digest nonce, authentication aborted'
+			);
+
+			return false;
+		}
+
+		if (!this.#realm) {
+			logger.warn(
+				'authenticate() | challenge without Digest realm, authentication aborted'
+			);
+
+			return false;
+		}
+
+		// If no plain SIP password is provided.
+		if (!this.#credentials.password) {
+			// If ha1 is not provided we cannot authenticate.
+			if (!this.#credentials.ha1) {
+				logger.warn(
+					'authenticate() | no plain SIP password nor ha1 provided, authentication aborted'
+				);
+
+				return false;
+			}
+
+			// If the realm does not match the stored realm we cannot authenticate.
+			if (this.#credentials.realm !== this.#realm) {
+				logger.warn(
+					'authenticate() | no plain SIP password, and stored `realm` does not match the given `realm`, cannot authenticate [stored:"%s", given:"%s"]',
+					this.#credentials.realm,
+					this.#realm
+				);
+
+				return false;
+			}
+		}
+
+		// 'qop' can contain a list of values (Array). Let's choose just one.
+		if (challenge.qop) {
+			if (challenge.qop.includes('auth-int')) {
+				this.#qop = 'auth-int';
+			} else if (challenge.qop.includes('auth')) {
+				this.#qop = 'auth';
+			} else {
+				// Otherwise 'qop' is present but does not contain 'auth' or 'auth-int', so abort here.
+				logger.warn(
+					'authenticate() | challenge without Digest qop different than "auth" or "auth-int", authentication aborted'
+				);
+
+				return false;
+			}
+		} else {
+			this.#qop = null;
+		}
+
+		// Fill other attributes.
+
+		this.#method = method;
+		this.#uri = ruri;
+		this.#cnonce = cnonce || createRandomToken(12);
+		this.#nc += 1;
+		const hex = Number(this.#nc).toString(16);
+
+		this.#ncHex = '00000000'.substr(0, 8 - hex.length) + hex;
+
+		// Nc-value = 8LHEX. Max value = 'FFFFFFFF'.
+		if (this.#nc === 4294967296) {
+			this.#nc = 1;
+			this.#ncHex = '00000001';
+		}
+
+		// Calculate the Digest "response" value.
+
+		// If we have plain SIP password then regenerate ha1.
+		if (this.#credentials.password) {
+			// HA1 = MD5(A1) = MD5(username:realm:password).
+			this.#ha1 = calculateMD5(
+				`${this.#credentials.username}:${this.#realm}:${this.#credentials.password}`
+			);
+		}
+		// Otherwise reuse the stored ha1.
+		else {
+			this.#ha1 = this.#credentials.ha1;
+		}
+
+		let a2;
+		let ha2;
+
+		if (this.#qop === 'auth') {
+			// HA2 = MD5(A2) = MD5(method:digestURI).
+			a2 = `${this.#method}:${this.#uri}`;
+			ha2 = calculateMD5(a2);
+
+			logger.debug('authenticate() | using qop=auth [a2:"%s"]', a2);
+
+			// Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2).
+			this.#response = calculateMD5(
+				`${this.#ha1}:${this.#nonce}:${this.#ncHex}:${this.#cnonce}:auth:${ha2}`
+			);
+		} else if (this.#qop === 'auth-int') {
+			// HA2 = MD5(A2) = MD5(method:digestURI:MD5(entityBody)).
+			a2 = `${this.#method}:${this.#uri}:${calculateMD5(body ? body : '')}`;
+			ha2 = calculateMD5(a2);
+
+			logger.debug('authenticate() | using qop=auth-int [a2:"%s"]', a2);
+
+			// Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2).
+			this.#response = calculateMD5(
+				`${this.#ha1}:${this.#nonce}:${this.#ncHex}:${this.#cnonce}:auth-int:${ha2}`
+			);
+		} else if (this.#qop === null) {
+			// HA2 = MD5(A2) = MD5(method:digestURI).
+			a2 = `${this.#method}:${this.#uri}`;
+			ha2 = calculateMD5(a2);
+
+			logger.debug('authenticate() | using qop=null [a2:"%s"]', a2);
+
+			// Response = MD5(HA1:nonce:HA2).
+			this.#response = calculateMD5(`${this.#ha1}:${this.#nonce}:${ha2}`);
+		}
+
+		logger.debug('authenticate() | response generated');
+
+		return true;
+	}
+
+	/**
+	 * Return the Proxy-Authorization or WWW-Authorization header value.
+	 */
+	toString(): string {
+		const auth_params: string[] = [];
+
+		if (!this.#response) {
+			throw new Error(
+				'response field does not exist, cannot generate Authorization header'
+			);
+		}
+
+		auth_params.push(`algorithm=${this.#algorithm}`);
+		auth_params.push(`username="${this.#credentials.username}"`);
+		auth_params.push(`realm="${this.#realm}"`);
+		auth_params.push(`nonce="${this.#nonce}"`);
+		auth_params.push(`uri="${this.#uri}"`);
+		auth_params.push(`response="${this.#response}"`);
+		if (this.#opaque) {
+			auth_params.push(`opaque="${this.#opaque}"`);
+		}
+		if (this.#qop) {
+			auth_params.push(`qop=${this.#qop}`);
+			auth_params.push(`cnonce="${this.#cnonce}"`);
+			auth_params.push(`nc=${this.#ncHex}`);
+		}
+
+		return `Digest ${auth_params.join(', ')}`;
+	}
+}
diff --git a/src/Logger.ts b/src/Logger.ts
index f9927fd..c571c20 100644
--- a/src/Logger.ts
+++ b/src/Logger.ts
@@ -3,37 +3,37 @@ import debug from 'debug';
 const APP_NAME = 'JsSIP';

 export class Logger {
-	#_debug: debug.Debugger;
-	#_warn: debug.Debugger;
-	#_error: debug.Debugger;
+	#debug: debug.Debugger;
+	#warn: debug.Debugger;
+	#error: debug.Debugger;

 	constructor(prefix?: string) {
 		if (prefix) {
-			this.#_debug = debug(`${APP_NAME}:${prefix}`);
-			this.#_warn = debug(`${APP_NAME}:WARN:${prefix}`);
-			this.#_error = debug(`${APP_NAME}:ERROR:${prefix}`);
+			this.#debug = debug(`${APP_NAME}:${prefix}`);
+			this.#warn = debug(`${APP_NAME}:WARN:${prefix}`);
+			this.#error = debug(`${APP_NAME}:ERROR:${prefix}`);
 		} else {
-			this.#_debug = debug(APP_NAME);
-			this.#_warn = debug(`${APP_NAME}:WARN`);
-			this.#_error = debug(`${APP_NAME}:ERROR`);
+			this.#debug = debug(APP_NAME);
+			this.#warn = debug(`${APP_NAME}:WARN`);
+			this.#error = debug(`${APP_NAME}:ERROR`);
 		}

 		/* eslint-disable no-console */
-		this.#_debug.log = console.info.bind(console);
-		this.#_warn.log = console.warn.bind(console);
-		this.#_error.log = console.error.bind(console);
+		this.#debug.log = console.info.bind(console);
+		this.#warn.log = console.warn.bind(console);
+		this.#error.log = console.error.bind(console);
 		/* eslint-enable no-console */
 	}

 	get debug(): debug.Debugger {
-		return this.#_debug;
+		return this.#debug;
 	}

 	get warn(): debug.Debugger {
-		return this.#_warn;
+		return this.#warn;
 	}

 	get error(): debug.Debugger {
-		return this.#_error;
+		return this.#error;
 	}
 }
diff --git a/src/RequestSender.js b/src/RequestSender.js
index 50d4faa..1b6e198 100644
--- a/src/RequestSender.js
+++ b/src/RequestSender.js
@@ -1,6 +1,6 @@
 const { Logger } = require('./Logger');
 const JsSIP_C = require('./Constants');
-const DigestAuthentication = require('./DigestAuthentication');
+const { DigestAuthentication } = require('./DigestAuthentication');
 const Transactions = require('./Transactions');

 const logger = new Logger('RequestSender');
diff --git a/src/Utils.d.ts b/src/Utils.d.ts
index 051a04c..9eb0f30 100644
--- a/src/Utils.d.ts
+++ b/src/Utils.d.ts
@@ -3,6 +3,9 @@ import { causes } from './Constants';

 export function str_utf8_length(str: string): number;

+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function isFunction(fn: unknown): fn is (...args: any[]) => any;
+
 export function isString(str: unknown): str is string;

 export function isDecimal(num: unknown): num is number;
@@ -12,6 +15,8 @@ export function isEmpty(value: unknown): boolean;
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export function hasMethods(obj: any, ...methodNames: string[]): boolean;

+export function createRandomToken(size: number, base?: number): string;
+
 export function newTag(): string;

 export function newUUID(): string;
diff --git a/src/test/test-DigestAuthentication.ts b/src/test/test-DigestAuthentication.ts
index 28a980b..3acad50 100644
--- a/src/test/test-DigestAuthentication.ts
+++ b/src/test/test-DigestAuthentication.ts
@@ -1,6 +1,5 @@
 import './include/common';
-// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
-const DigestAuthentication: any = require('../DigestAuthentication.js');
+import { DigestAuthentication } from '../DigestAuthentication';

 // Results of this tests originally obtained from RFC 2617 and:
 // 'https://pernau.at/kd/sipdigest.php'
@@ -29,7 +28,7 @@ describe('DigestAuthentication', () => {

 		digest.authenticate({ method, ruri }, challenge, cnonce);

-		expect(digest._response).toBe('6629fae49393a05397450978507c4ef1');
+		expect(digest.response).toBe('6629fae49393a05397450978507c4ef1');
 	});

 	test('digest authenticate qop = null', () => {
@@ -54,7 +53,7 @@ describe('DigestAuthentication', () => {

 		digest.authenticate({ method, ruri }, challenge);

-		expect(digest._response).toBe('f99e05f591f147facbc94ff23b4b1dee');
+		expect(digest.response).toBe('f99e05f591f147facbc94ff23b4b1dee');
 	});

 	test('digest authenticate qop = auth', () => {
@@ -80,7 +79,7 @@ describe('DigestAuthentication', () => {

 		digest.authenticate({ method, ruri }, challenge, cnonce);

-		expect(digest._response).toBe('a69b9c2ea0dea1437a21df6ddc9b05e4');
+		expect(digest.response).toBe('a69b9c2ea0dea1437a21df6ddc9b05e4');
 	});

 	test('digest authenticate qop = auth-int and empty body', () => {
@@ -106,7 +105,7 @@ describe('DigestAuthentication', () => {

 		digest.authenticate({ method, ruri }, challenge, cnonce);

-		expect(digest._response).toBe('82b3cab8b1c4df404434db6a0581650c');
+		expect(digest.response).toBe('82b3cab8b1c4df404434db6a0581650c');
 	});

 	test('digest authenticate qop = auth-int and non-empty body', () => {
@@ -133,6 +132,6 @@ describe('DigestAuthentication', () => {

 		digest.authenticate({ method, ruri, body }, challenge, cnonce);

-		expect(digest._response).toBe('7bf0e9de3fbb5da121974509d617f532');
+		expect(digest.response).toBe('7bf0e9de3fbb5da121974509d617f532');
 	});
 });