Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/Registrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
77 changes: 77 additions & 0 deletions src/RequestSender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'));
Expand Down
207 changes: 207 additions & 0 deletions src/test/test-ProactiveAuth.ts
Original file line number Diff line number Diff line change
@@ -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'];
});
});