diff --git a/src/Registrator.js b/src/Registrator.js index 7ce227b0..16f380cf 100644 --- a/src/Registrator.js +++ b/src/Registrator.js @@ -94,6 +94,8 @@ module.exports = class Registrator { const extraHeaders = Utils.cloneArray(this._extraHeaders); let contactValue; + // Proactive Authorization: Authorization header will be added automatically + // by RequestSender if cached credentials are available from previous auth challenges. if (this._expires) { contactValue = `${this._contact};expires=${this._expires}${this._extraContactParams}`; diff --git a/src/RequestSender.js b/src/RequestSender.js index c7ddc358..4bf58f58 100644 --- a/src/RequestSender.js +++ b/src/RequestSender.js @@ -14,6 +14,9 @@ const EventHandlers = { }; module.exports = class RequestSender { + // Static cache for proactive authorization credentials + static _authCache = {}; + constructor(ua, request, eventHandlers) { this._ua = ua; this._eventHandlers = eventHandlers; @@ -22,6 +25,7 @@ module.exports = class RequestSender { this._auth = null; this._challenged = false; this._staled = false; + this._proactiveAuth = false; // Define the undefined handlers. for (const handler in EventHandlers) { @@ -57,6 +61,9 @@ module.exports = class RequestSender { }, }; + // Try proactive authorization if we have cached credentials. + this._attemptProactiveAuth(); + switch (this._method) { case 'INVITE': { this.clientTransaction = new Transactions.InviteClientTransaction( @@ -96,6 +103,60 @@ module.exports = class RequestSender { this.clientTransaction.send(); } + /** + * Attempt proactive authorization using cached credentials. + * This avoids the need to wait for a 401/407 challenge. + */ + _attemptProactiveAuth() { + const cacheKey = this._ua.configuration.registrar_server; + const cachedAuth = RequestSender._authCache[cacheKey]; + + if (!cachedAuth) { + return; + } + + try { + // Create a digest authentication object from cached credentials + this._auth = new DigestAuthentication({ + username: this._ua.configuration.authorization_user, + password: this._ua.configuration.password, + realm: this._ua.configuration.realm, + ha1: this._ua.configuration.ha1, + }); + + // Restore nonce count state from cache to maintain replay protection + // RFC 2617: nonce count must increase for each request with same nonce + this._auth._nc = cachedAuth.nc || 0; + this._auth._ncHex = cachedAuth.ncHex || '00000000'; + this._auth._cnonce = cachedAuth.cnonce || null; + + // Set authentication parameters from cache + this._auth._realm = cachedAuth.realm; + this._auth._nonce = cachedAuth.nonce; + this._auth._opaque = cachedAuth.opaque; + this._auth._algorithm = cachedAuth.algorithm; + this._auth._qop = cachedAuth.qop; + + // Authenticate the request + if ( + this._auth.authenticate(this._request, { + realm: cachedAuth.realm, + nonce: cachedAuth.nonce, + opaque: cachedAuth.opaque, + algorithm: cachedAuth.algorithm, + qop: cachedAuth.qop, + stale: false, + }) + ) { + this._request.setHeader('authorization', this._auth.toString()); + this._proactiveAuth = true; + logger.debug('Proactive authorization header added'); + } + } catch (e) { + logger.debug('Proactive authentication failed:', e.message); + } + } + /** * Called from client transaction when receiving a correct response to the request. * Authenticate request if needed or pass the response back to the applicant. @@ -151,6 +212,22 @@ module.exports = class RequestSender { } this._challenged = true; + // Cache authentication credentials for proactive authorization. + // Include nonce count state to maintain RFC 2617 replay protection + const cacheKey = this._ua.configuration.registrar_server; + + RequestSender._authCache[cacheKey] = { + realm: challenge.realm, + nonce: challenge.nonce, + opaque: challenge.opaque, + algorithm: challenge.algorithm, + qop: challenge.qop, + nc: this._auth._nc, // Store current nonce count + ncHex: this._auth._ncHex, // Store hex representation + cnonce: this._auth._cnonce, // Store client nonce for qop support + }; + logger.debug('Authentication credentials cached for proactive auth'); + // Update ha1 and realm in the UA. this._ua.set('realm', this._auth.get('realm')); this._ua.set('ha1', this._auth.get('ha1')); diff --git a/src/test/test-ProactiveAuth.ts b/src/test/test-ProactiveAuth.ts new file mode 100644 index 00000000..3323a1e8 --- /dev/null +++ b/src/test/test-ProactiveAuth.ts @@ -0,0 +1,207 @@ +import './include/common'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const RequestSender = require('../RequestSender.js'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DigestAuthentication = require('../DigestAuthentication.js'); + +describe('Proactive Authorization - Nonce Count Management', () => { + test('should maintain increasing nonce count across multiple requests', () => { + // Create a mock UA and request + const mockUA = { + status: 0, + C: { STATUS_USER_CLOSED: 10 }, + configuration: { + registrar_server: 'sip.example.com', + authorization_user: 'testuser', + password: 'testpass', + realm: 'example.com', + ha1: null, + authorization_jwt: null, + uri: 'sip:testuser@example.com', + use_preloaded_route: false, + extra_headers: null, + }, + transport: { + sip_uri: 'sip:proxy.example.com', + }, + set: () => {}, + }; + + const mockRequest = { + method: 'REGISTER', + ruri: 'sip:example.com', + body: null, + headers: {}, + setHeader: () => {}, + clone: function () { + return { ...this }; + }, + cseq: 1, + }; + + const eventHandlers = { + onRequestTimeout: () => {}, + onTransportError: () => {}, + onReceiveResponse: () => {}, + onAuthenticated: () => {}, + }; + + // Simulate first authentication after 401 challenge + const sender1 = new RequestSender(mockUA, mockRequest, eventHandlers); + + sender1._auth = new DigestAuthentication({ + username: 'testuser', + password: 'testpass', + realm: 'example.com', + ha1: null, + }); + + // Simulate receiving a challenge and authenticating + const challenge = { + algorithm: 'MD5', + realm: 'example.com', + nonce: 'abcd1234', + opaque: null, + stale: null, + qop: 'auth', + }; + + sender1._auth.authenticate(mockRequest, challenge); + expect(sender1._auth._nc).toBe(1); // First use + expect(sender1._auth._ncHex).toBe('00000001'); + + // Cache the credentials (as would happen in _receiveResponse) + RequestSender._authCache['sip.example.com'] = { + realm: challenge.realm, + nonce: challenge.nonce, + opaque: challenge.opaque, + algorithm: challenge.algorithm, + qop: challenge.qop, + nc: sender1._auth._nc, + ncHex: sender1._auth._ncHex, + cnonce: sender1._auth._cnonce, + }; + + // Verify cache has correct values + const cached = RequestSender._authCache['sip.example.com']; + + expect(cached.nc).toBe(1); + expect(cached.ncHex).toBe('00000001'); + + // Create a second request that will attempt proactive auth + const mockRequest2 = { + method: 'MESSAGE', + ruri: 'sip:example.com', + body: null, + headers: {}, + setHeader: () => {}, + clone: function () { + return { ...this }; + }, + cseq: 2, + }; + + const sender2 = new RequestSender(mockUA, mockRequest2, eventHandlers); + + // Trigger proactive authorization - should restore nonce count from cache + sender2._attemptProactiveAuth(); + + // Verify that the second request has incremented nonce count + expect(sender2._auth._nc).toBe(2); // Should be 2, not 1! + expect(sender2._auth._ncHex).toBe('00000002'); + expect(sender2._proactiveAuth).toBe(true); + + // The cached value should now be updated to 2 + // (This would be updated when the response is received) + RequestSender._authCache['sip.example.com'].nc = sender2._auth._nc; + RequestSender._authCache['sip.example.com'].ncHex = sender2._auth._ncHex; + + // Create a third request to verify continuous increment + const mockRequest3 = { + method: 'OPTIONS', + ruri: 'sip:example.com', + body: null, + headers: {}, + setHeader: () => {}, + clone: function () { + return { ...this }; + }, + cseq: 3, + }; + + const sender3 = new RequestSender(mockUA, mockRequest3, eventHandlers); + + sender3._attemptProactiveAuth(); + + // Verify continuous increment + expect(sender3._auth._nc).toBe(3); + expect(sender3._auth._ncHex).toBe('00000003'); + expect(sender3._proactiveAuth).toBe(true); + + // Clean up + delete RequestSender._authCache['sip.example.com']; + }); + + test('should handle missing nonce count in cached auth (backward compatibility)', () => { + const mockUA = { + status: 0, + C: { STATUS_USER_CLOSED: 10 }, + configuration: { + registrar_server: 'sip.example.com', + authorization_user: 'testuser', + password: 'testpass', + realm: 'example.com', + ha1: null, + authorization_jwt: null, + uri: 'sip:testuser@example.com', + use_preloaded_route: false, + extra_headers: null, + }, + transport: { + sip_uri: 'sip:proxy.example.com', + }, + set: () => {}, + }; + + const mockRequest = { + method: 'REGISTER', + ruri: 'sip:example.com', + body: null, + headers: {}, + setHeader: () => {}, + clone: function () { + return { ...this }; + }, + cseq: 1, + }; + + const eventHandlers = { + onRequestTimeout: () => {}, + onTransportError: () => {}, + onReceiveResponse: () => {}, + onAuthenticated: () => {}, + }; + + // Simulate old cache format without nonce count (backward compatibility) + RequestSender._authCache['sip.example.com'] = { + realm: 'example.com', + nonce: 'oldnonce', + opaque: null, + algorithm: 'MD5', + qop: 'auth', + // Missing: nc, ncHex, cnonce + }; + + const sender = new RequestSender(mockUA, mockRequest, eventHandlers); + + sender._attemptProactiveAuth(); + + // Should default to 0, then increment to 1 + expect(sender._auth._nc).toBe(1); + expect(sender._auth._ncHex).toBe('00000001'); + + // Clean up + delete RequestSender._authCache['sip.example.com']; + }); +});