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