pyVoIP
pyVoIP copied to clipboard
WWW-Authenticate header Stale flag
In the register is a part for improvement.
if response.status == SIPStatus(401):
# Unauthorized, likely due to being password protected.
regRequest = self.genRegister(response)
self.out.sendto(
regRequest.encode("utf8"), (self.server, self.port)
)
ready = select.select([self.s], [], [], self.register_timeout)
if ready[0]:
resp = self.s.recv(8192)
response = SIPMessage(resp)
response = self.trying_timeout_check(response)
if response.status == SIPStatus(401):
# At this point, it's reasonable to assume that
# this is caused by invalid credentials.
When we received the second 401 there can be a flag stale=[true|false] in the response. When stale = true the username + password are okay but the nonce is invalid. In the new 401 response is a new nonce that can be used for a next retry.
WWW-Authenticate: Digest realm="sip2sip.info", nonce="Sg0dKZHIwBmsBG7KZQkhyLeU2ZNxk5toPlm6Pnlbz40A", qop="auth,auth-int", stale=true
In the moment I'm fightig the + or / in the nonce. When I received a nonce wth + or /, I'm getting a 401 with stale = true. When I resend a new register request with the new nonce, everything is fine. The stale value is in response.authentication['stale'] without any changes to the code.
What version are you using?
I'm using my own version, which is not checked in in the moment. It's near to your development branch. What I did as quick & dirty fix, is resending the request with a new calculated response and the new nonce.
RFC 2617:
stale A flag, indicating that the previous request from the client was rejected because the nonce value was stale. If stale is TRUE (case-insensitive), the client may wish to simply retry the request with a new encrypted response, without reprompting the user for a new username and password. The server should only set stale to TRUE if it receives a request for which the nonce is invalid but with a valid digest for that nonce (indicating that the client knows the correct username/password). If stale is FALSE, or anything other than TRUE, or the stale directive is not present, the username and/or password are invalid, and new values must be obtained.
Likely experiencing the same issue. Randomly getting responses with stale=true which causes pyVoIP to assume that invalid credentials have been used. Could you please share your workaround in order for me to test that it resolves my issue as well?
The quit and dirty fix is to retry it in error part. Please be aware, that I did some deeper changes in my code to use proxy and IPv6. In my code it looks like:
if response.status == SIPStatus(401):
# Unauthorized, likely due to being password protected.
reg_request = self.gen_register(response)
debug('DEBUG', f"Vergleiche nonce response {response.authentication['nonce']}")
self.send_message(reg_request)
ready = select.select([self.s], [], [], self.register_timeout)
if ready[0]:
resp = self.s.recv(8192)
response = SIPMessage(resp)
debug('DEBUG',
f"{self.__class__.__name__}.{inspect.stack()[0][3]} "
f"<-- received Message register 3\n----\n{resp}\n----\n")
if response.status == SIPStatus(401):
# At this point, it's reasonable to assume that
# this is caused by invalid credentials.
debug('DEBUG', f"Unauthorized Is stale available: {response.authentication['stale']}")
if(response.authentication['stale'] == "true"):
reg_request = self.gen_register(response)
self.send_message(reg_request)
ready = select.select([self.s], [], [], self.register_timeout)
if ready[0]:
resp = self.s.recv(8192)
response = SIPMessage(resp)
debug('DEBUG',
f"{self.__class__.__name__}.{inspect.stack()[0][3]} "
f"<-- received Message register 4\n----\n{resp}\n----\n")
if response.status == SIPStatus(401):
raise InvalidAccountInfoError("Invalid Username or " +
"Password for SIP server " +
f"{self.sip_server.get_address()}:" +
f"{self.sip_server.get_port()}")
else:
raise InvalidAccountInfoError("Invalid Username or " +
"Password for SIP server " +
f"{self.sip_server.get_address()}:" +
f"{self.sip_server.get_port()}")
elif response.status == SIPStatus(400):
# Bad Request
# TODO: implement
# TODO: check if broken connection can be brought back
# with new urn:uuid or reply with expire 0
self._handle_bad_request()
else:
raise TimeoutError('Registering on SIP Server timed out')
Does anyone have an idea, why this can happen? When I receive a nonce like this "637be04a008d203009b48e02599e02ee3783e316" erverything works fine. When I receive a nonce like this "OphMIgjxHMki4GtCyl4jflKogHSER5cUTQwKg5KE2FIA" I get a stale flag back. It looks like, that the creation of the response for the authentication is good. When I change something in the password or username I don't get the stale flag back only the 401.
I had some time checking if your changes resolve my issue and they do not. I get the stale flag frequently on an AWS EC2 Instance while there are almost never stale flags when trying from my Laptop (Behind my Router without any Portforwarding configured). I also cannot see any patterns that the issue is caused by a difference in the nonce values. Nonces that are followed by a stale=TRUE and nonces that result in a 200 OK look exactly the same.
Anyone has an idea why in my case the stale flag seems to be so much more frequent on the AWS Instance?
My problem comes with opensip on a sip2sip.info server. it looks like that the changed the creation of nonce.
For my problem I found the solution. The problem is in a different way to calculate the response. I add self.nonce_count = Counter() in the init part of the sip client and changed the gen_authorization part in the sip client
def gen_authorization(self, request):
debug('INFO', f'{self.__class__.__name__}.{inspect.stack()[0][3]} called from '
f'{inspect.stack()[1][0].f_locals["self"].__class__.__name__}.{inspect.stack()[1][3]} start')
realm = request.authentication['realm']
nonce = request.authentication['nonce']
ha1 = f'{self.username}:{realm}:{self.password}'
ha1 = hashlib.md5(ha1.encode('utf8')).hexdigest()
ha2 = f'{request.headers["CSeq"]["method"]}:sip:{self.sip_server.get_address()};transport=UDP'
ha2 = hashlib.md5(ha2.encode('utf8')).hexdigest()
if request.authentication.get('qop') and "auth" in request.authentication.get('qop'):
cnonce = self.gen_tag()
nonce_count = self.nonce_count.next()
response = f'{ha1}:{nonce}:{nonce_count}:{cnonce}:auth:{ha2}'.encode('utf8')
response = hashlib.md5(response).hexdigest().encode('utf8')
response = f'qop="auth",nc="{nonce_count}",cnonce="{cnonce}",' \
f'response="{str(response, "utf8")}"'
else:
response = f'{ha1}:{nonce}:{ha2}'.encode('utf8')
response = hashlib.md5(response).hexdigest().encode('utf8')
response = f'response="{str(response, "utf8")}"'
return response
And in gen_register I've changed the Authorization part to:
response = self.gen_authorization(request)
...
reg_request += f'Authorization: Digest username="{self.username}",' + \
f'realm="{realm}",nonce="{nonce}",' + \
f'uri="sip:{self.sip_server.get_address()};transport=UDP",' \
f'{response},algorithm=MD5\r\n'
I register I've changes my workaround a little bit
if response.status == SIPStatus(401):
# Unauthorized, likely due to being password protected.
reg_request = self.gen_register(response)
debug('DEBUG', f"Vergleiche nonce response {response.authentication['nonce']}")
self.send_message(reg_request)
ready = select.select([self.s], [], [], self.register_timeout)
if ready[0]:
resp = self.s.recv(8192)
response = SIPMessage(resp)
debug('DEBUG',
f"{self.__class__.__name__}.{inspect.stack()[0][3]} "
f"<-- received Message register 3\n----\n{resp}\n----\n")
if response.status == SIPStatus(401):
# At this point, it's reasonable to assume that
# this is caused by invalid credentials.
debug('DEBUG', f"Unauthorized Is stale available: {response.authentication.get('stale')}")
if response.authentication.get('stale') == "true":
reg_request = self.gen_register(response)
self.send_message(reg_request)
ready = select.select([self.s], [], [], self.register_timeout)
if ready[0]:
resp = self.s.recv(8192)
response = SIPMessage(resp)
debug('DEBUG',
f"{self.__class__.__name__}.{inspect.stack()[0][3]} "
f"<-- received Message register 4\n----\n{resp}\n----\n")
if response.status == SIPStatus(401):
raise InvalidAccountInfoError("Invalid Username or " +
"Password for SIP server " +
f"{self.sip_server.get_address()}:" +
f"{self.sip_server.get_port()}")
else:
raise InvalidAccountInfoError("Invalid Username or " +
"Password for SIP server " +
f"{self.sip_server.get_address()}:" +
f"{self.sip_server.get_port()}")
Thanks, will validate if this has an effect on my issues as well!
@dani2112 Is it working for you?
@xyc0815 I tried to quickly adopt your changes to my fork and it seems like it didn't solve the problem. However there is a possibility that I simply messed something up. Feel free to look at my fork to validate if I implemented your changes correctly: dani2112/pyVoIP
I will have to dig into this a little more I guess...
@dani2112 Can you send me an log from the response header and the return request? The only different I see is the way the authorization header is parsed. I'm usind the old way. I don't know if qop and the rest is parsed right in the new way. You use
elif header == "WWW-Authenticate" or header == "Authorization":
data = data.replace("Digest ", "")
row_data = self.auth_match.findall(data)
header_data = {}
for var, data in row_data:
header_data[var] = data.strip('"')
self.headers[header] = header_data
self.authentication = header_data
else:
self.headers[header] = data
And I use
elif header == "WWW-Authenticate" or header == "Authorization":
data = data.replace("Digest", "")
# fix issue 41 part 2
# add blank to avoid the split of qop="auth,auth-int"
info = data.split(", ")
header_data = {}
for x in info:
x = x.strip()
header_data[x.split('=')[0]] = x.split('=')[1].strip('"')
self.headers[header] = header_data
self.authentication = header_data
else:
self.headers[header] = data
Maybe you also can log the var, data value in the header parser to look if the parser worked fine.
@dani2112, @tayler6000 When I use the new was to split the authentication header it is not working for me. I've change this
self.auth_match = re.compile(r'(\w+)=("[^",]+"|[^ \t,]+)')
to this
self.auth_match = re.compile(r'(\w+)=(\"[^\",\s+]+\"|[^ \t]+)')
In the first statement the qop element is not parsed fine and didn't work. Like in my old patch you must split comma + space to get all elements for that we need ,\s+ and the second comma in \t, is to much. The replace of Digest is not needed with this regex.
Here my short test script.
import re
def test_header_auth(data):
print("************************")
print(f'data to split: {data}')
#auth_match = re.compile(r'(\w+)=("[^",]+"|[^ \t,]+)')
auth_match = re.compile(r'(\w+)=(\"[^\",\s+]+\"|[^ \t]+)')
# data = data.replace("Digest ", "")
row_data = auth_match.findall(data)
for var, data in row_data:
print(f'header_var {var} : header_data {data}')
test_header_auth('Digest realm="sip2sip.info", nonce="Sg0dKZHIwBmsBG7KZQkhyLeU2ZNxk5toPlm6Pnlbz40A"')
test_header_auth('Digest realm="sip2sip.info", nonce="Sg0dKZHIwBmsBG7KZQkhyLeU2ZNxk5toPlm6Pnlbz40A", qop="auth,auth-int"')
test_header_auth('Digest realm="sip2sip.info", nonce="Sg0dKZHIwBmsBG7KZQkhyLeU2ZNxk5toPlm6Pnlbz40A", qop="auth,auth-int", stale=true')
test_header_auth('Digest realm="sip2sip.info", nonce="Sg0dKZHIwBmsBG7KZQkhyLeU2ZNxk5toPlm6Pnlbz40A", qop="auth,auth-int", opaque="5ccc069c403ebaf9f0171e9517f40e41", stale=true')
@xyc0815 Thanks! I will check if the changed split regex does anything for my problem. Probably will have feedback this evening.
@dani2112 I you still have trouble, can you please post the last request and response?
@xyc0815 Hey, so this didn't resolve my issue. Is a dump of the packets acquired via tcpdump alright?
`user@ip:~$ sudo tcpdump -nqt -s 0 -A -i eth0 port 5060 tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes IP xxxxxxxxxxxxxx.5060 > xxxxxxxxxxxxxxxxxxxx.5060: UDP, length 614 E.....@.@.^... .........n..REGISTER sip:server SIP/2.0 Via: SIP/2.0/UDP 0.0.0.0:5060;branch=z9hG4bK01db0bb26cc941e0826383384;rport From: "user" sip:user@server;tag=6a1e393c To: "user" sip:user@server Call-ID: [email protected]:5060 CSeq: 1 REGISTER Contact: sip:[email protected]:5060;transport=UDP;+sip.instance="urn:uuid:3C9423B4-0966-4794-9B5C-DBF75F1DE877" Allow: INVITE, ACK, BYE, CANCEL Max-Forwards: 70 Allow-Events: org.3gpp.nwinitdereg User-Agent: pyVoIP 1.6.2 Expires: 120 Content-Length: 0
IP xxxxxxxxxxxxxx.5060 > xxxxxxxxxxxxxx.5060: UDP, length 746 E....&..6.$-..... .......<$SIP/2.0 401 Unauthorized Via: SIP/2.0/UDP 0.0.0.0:5060;branch=z9hG4bK01db0bb26cc941e0826383384;rport=5060;received=xxxxxxxx From: "user" sip:user@server;tag=6a1e393c To: "user" sip:user@server;tag=8ayge6g251cvD Call-ID: [email protected]:5060 CSeq: 1 REGISTER User-Agent: FreeSWITCH-mod_sofia/1.10.7-release+git~20220701T135949Z~43de83d994~64bit Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, MESSAGE, INFO, UPDATE, REGISTER, REFER, NOTIFY, PUBLISH, SUBSCRIBE Supported: timer, path, replaces WWW-Authenticate: Digest realm="server", nonce="d6738053-7def-478f-9044-25d5969b91c4", algorithm=MD5, qop="auth" Content-Length: 0
IP xxxxxxxxxxxxxxxx.5060 > xxxxxxxxxxxxxx.5060: UDP, length 869 E.....@.@..... .........m..REGISTER sip:server SIP/2.0 Via: SIP/2.0/UDP 0.0.0.0:5060;branch=z9hG4bK06ad8029d45e49a99d9698407;rport From: "user" sip:user@server;tag=6a1e393c To: "user" sip:user@server Call-ID: [email protected]:5060 CSeq: 2 REGISTER Contact: sip:[email protected]:5060;transport=UDP;+sip.instance="urn:uuid:3C9423B4-0966-4794-9B5C-DBF75F1DE877" Allow: INVITE, ACK, BYE, CANCEL Max-Forwards: 70 Allow-Events: org.3gpp.nwinitdereg User-Agent: pyVoIP 1.6.2 Expires: 120 Authorization: Digest username="user",realm="server",nonce="d6738053-7def-478f-9044-25d5969b91c4",uri="sip:server;transport=UDP",qop="auth",nc="1",cnonce="25bb8d21",response="xxxxxxxxxxxxxxxxxxxx",algorithm=MD5 Content-Length: 0
IP xxxxxxx.17.5060 > xxxxxxxxxxx.5060: UDP, length 758 E.......6.5...... .......w.SIP/2.0 401 Unauthorized Via: SIP/2.0/UDP 0.0.0.0:5060;branch=z9hG4bK06ad8029d45e49a99d9698407;rport=5060;received=xxxxxxxxx From: "user" sip:user@server;tag=6a1e393c To: "user" sip:user@server;tag=v6aNQ0ycpvK0S Call-ID: [email protected]:5060 CSeq: 2 REGISTER User-Agent: FreeSWITCH-mod_sofia/1.10.7-release+git~20220701T135949Z~43de83d994~64bit Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, MESSAGE, INFO, UPDATE, REGISTER, REFER, NOTIFY, PUBLISH, SUBSCRIBE Supported: timer, path, replaces WWW-Authenticate: Digest realm="server", nonce="961e21b8-6246-45f6-9b59-7db4c6e9a967", stale=true, algorithm=MD5, qop="auth" Content-Length: 0 `
@dani2112 I don't see a problem her. Maybe it can help to take a look into the freeswitch sourcecode, what the software is done with your data.
@dani2112 One thing I now saw, is that you change the callId in between, that is not good. I've changed in the def gen_register function the way to set the callId for reusing the existing callId.
# reg_request += f'Call-ID: {self.gen_call_id()}\r\n'
reg_request += f'Call-ID: {request.headers["Call-ID"]}\r\n'
In your fork is it line 1236.
@xyc0815 You are right, that definitely is an issue. I will have a look if that resolves my problems and report back. Thanks a lot!
@dani2112 Yesterday I had a session on the gen_authorization methode by reading https://datatracker.ietf.org/doc/html/draft-smith-sip-auth-examples-00.
After this I made a small by creating the nonce count. The nonce count should be used as hex value in the format nc="00000001"
if request.authentication.get('qop') and ("auth" or "auth-int") in request.authentication.get('qop'):
cnonce = self.gen_tag()
# For better use add as integer for the transfer send as hex with leading zeros.
# example: nc="00000001"
nonce_count = self.nonce_count.next()
response = f'{ha1}:{nonce}:{nonce_count:08x}:{cnonce}:auth:{ha2}'.encode('utf8')
response = hashlib.md5(response).hexdigest().encode('utf8')
response = f'qop="auth",nc="{nonce_count:08x}",cnonce="{cnonce}",' \
f'response="{str(response, "utf8")}"'
For a simpler use I take the counter as normal integer. For the use I use the value like {nonce_count:08x}. Maybe this solved your problem.