@@ -54,7 +54,63 @@ def canon_params(params):
5454 return '&' .join (args )
5555
5656
57- def canonicalize (method , host , uri , params , date , sig_version , body = None ):
57+ def canon_x_duo_headers (additional_headers ):
58+ """
59+ Args:
60+ additional_headers: Dict
61+ Returns:
62+ stringified version of all headers that start with 'X-Duo*'. Which is then hashed.
63+ Note: the keys are also lower-cased for signing.
64+ """
65+ if additional_headers is None :
66+ additional_headers = {}
67+
68+ # Lower the headers before sorting them
69+ lowered_headers = {}
70+ for header_name , header_value in additional_headers .items ():
71+ header_name = header_name .lower () if header_name is not None else None
72+ lowered_headers [header_name ] = header_value
73+
74+ canon_list = []
75+ added_headers = [] # store headers we've added, use for duplicate checking (case insensitive)
76+ for header_name in sorted (lowered_headers .keys ()):
77+ # Extract header value and set key to lower case from now on.
78+ value = lowered_headers [header_name ]
79+
80+ # Validation gate. We will raise if a problem is found here.
81+ _validate_additional_header (header_name , value , added_headers )
82+
83+ # Add to the list of values to canonicalize:
84+ canon_list .extend ([header_name , value ])
85+ added_headers .append (header_name )
86+
87+ canon = '\x00 ' .join (canon_list )
88+ return hashlib .sha512 (canon .encode ('utf-8' )).hexdigest ()
89+
90+
91+ def _validate_additional_header (header_name , value , added_headers ):
92+ """
93+ Args:
94+ header_name: str
95+ value: str
96+ added_headers: list[str] - headers we've already added - check for duplicates (case insensitive)
97+ Returns: None
98+
99+ Validates additional headers added to request - headers must comply with the following rules (for V5 sig_version)
100+ """
101+ if header_name is None or value is None :
102+ raise ValueError ("Not allowed 'None' as a header name or value" )
103+ if '\x00 ' in header_name :
104+ raise ValueError ("Not allowed 'Null' character in header name" )
105+ if '\x00 ' in value :
106+ raise ValueError ("Not allowed 'Null' character in header value" )
107+ if not header_name .lower ().startswith ('x-duo-' ):
108+ raise ValueError ("Additional headers must start with \' X-Duo-\' " )
109+ if header_name .lower () in added_headers :
110+ raise ValueError ("Duplicate header passed, header={}" .format (header_name ))
111+
112+
113+ def canonicalize (method , host , uri , params , date , sig_version , body = None , additional_headers = None ):
58114 """
59115 Return a canonical string version of the given request attributes.
60116
@@ -91,17 +147,27 @@ def canonicalize(method, host, uri, params, date, sig_version, body=None):
91147 canon_params (params ),
92148 hashlib .sha512 (body .encode ('utf-8' )).hexdigest (),
93149 ]
150+ elif sig_version == 5 :
151+ canon = [
152+ date ,
153+ method .upper (),
154+ host .lower (),
155+ uri ,
156+ canon_params (params ),
157+ hashlib .sha512 (body .encode ('utf-8' )).hexdigest (),
158+ canon_x_duo_headers (additional_headers ), # hashed in canon_x_duo_headers
159+ ]
94160 else :
95161 raise ValueError ("Unknown signature version: {}" .format (sig_version ))
96162 return '\n ' .join (canon )
97163
98164
99165def sign (ikey , skey , method , host , uri , date , sig_version , params , body = None ,
100- digestmod = hashlib .sha512 ):
166+ digestmod = hashlib .sha512 , additional_headers = None ):
101167 """
102168 Return basic authorization header line with a Duo Web API signature.
103169 """
104- canonical = canonicalize (method , host , uri , params , date , sig_version , body = body )
170+ canonical = canonicalize (method , host , uri , params , date , sig_version , body = body , additional_headers = additional_headers )
105171 if isinstance (skey , six .text_type ):
106172 skey = skey .encode ('utf-8' )
107173 if isinstance (canonical , six .text_type ):
@@ -146,15 +212,16 @@ def to_list(value):
146212
147213
148214class Client (object ):
215+ sig_version = 2
149216
150217 def __init__ (self , ikey , skey , host ,
151218 ca_certs = DEFAULT_CA_CERTS ,
152219 sig_timezone = 'UTC' ,
153220 user_agent = ('Duo API Python/' + __version__ ),
154221 timeout = socket ._GLOBAL_DEFAULT_TIMEOUT ,
155222 paging_limit = 100 ,
156- digestmod = hashlib .sha512 ,
157- sig_version = 2 ,
223+ digestmod = hashlib .sha512 ,
224+ sig_version = None ,
158225 port = None
159226 ):
160227 """
@@ -172,7 +239,8 @@ def __init__(self, ikey, skey, host,
172239 self .set_proxy (host = None , proxy_type = None )
173240 self .paging_limit = paging_limit
174241 self .digestmod = digestmod
175- self .sig_version = sig_version
242+ if sig_version is not None :
243+ self .sig_version = sig_version
176244
177245 # Constants for handling rate limit backoff and retries
178246 self ._MAX_BACKOFF_WAIT_SECS = 32
@@ -189,9 +257,6 @@ def __init__(self, ikey, skey, host,
189257 if sig_version == 3 :
190258 raise ValueError ('sig_version 3 not supported' )
191259
192- if sig_version == 4 and digestmod != hashlib .sha512 :
193- raise ValueError ('sha512 required for sig_version 4' )
194-
195260 def set_proxy (self , host , port = None , headers = None ,
196261 proxy_type = 'CONNECT' ):
197262 """
@@ -207,28 +272,45 @@ def set_proxy(self, host, port=None, headers=None,
207272 self .proxy_port = port
208273 self .proxy_type = proxy_type
209274
210- def api_call (self , method , path , params ):
275+ def api_call (
276+ self ,
277+ method ,
278+ path ,
279+ params ,
280+ additional_headers = None ,
281+ sig_version = None ,
282+ ):
211283 """
212284 Call a Duo API method. Return a (response, data) tuple.
213285
214286 * method: HTTP request method. E.g. "GET", "POST", or "DELETE".
215287 * path: Full path of the API endpoint. E.g. "/auth/v2/ping".
216288 * params: dict mapping from parameter name to stringified value,
217289 or a dict to be converted to json.
290+ * sig_version: signature version integer
218291 """
219292 params_go_in_body = method in ('POST' , 'PUT' , 'PATCH' )
220- if self .sig_version in (1 , 2 ):
293+ digestmod = self .digestmod
294+ if additional_headers is None :
295+ additional_headers = {}
296+ if sig_version is None :
297+ sig_version = self .sig_version
298+
299+ if sig_version in (1 , 2 ):
221300 params = normalize_params (params )
222301 # v1 and v2 canonicalization don't distinguish between
223302 # params and body. There's no separate body input.
224303 body = None
225- elif self .sig_version == 4 :
304+ elif sig_version in (4 , 5 ):
305+ digestmod = hashlib .sha512
226306 if params_go_in_body :
227307 body = self .canon_json (params )
228308 params = {}
229309 else :
230310 body = ''
231311 params = normalize_params (params )
312+ else :
313+ raise ValueError (f"unsupported sig_version { sig_version } " )
232314
233315 if self .sig_timezone == 'UTC' :
234316 now = email .utils .formatdate ()
@@ -244,20 +326,25 @@ def api_call(self, method, path, params):
244326 self .host ,
245327 path ,
246328 now ,
247- self . sig_version ,
329+ sig_version ,
248330 params ,
249331 body = body ,
250- digestmod = self .digestmod )
332+ digestmod = digestmod ,
333+ additional_headers = additional_headers )
251334 headers = {
252335 'Authorization' : auth ,
253336 'Date' : now ,
254337 }
255338
339+ if sig_version == 5 :
340+ for k , v in additional_headers .items ():
341+ headers [k ] = v
342+
256343 if self .user_agent :
257344 headers ['User-Agent' ] = self .user_agent
258345
259346 if params_go_in_body :
260- if self . sig_version == 4 :
347+ if sig_version in ( 4 , 5 ) :
261348 headers ['Content-type' ] = 'application/json'
262349 else :
263350 headers ['Content-type' ] = 'application/x-www-form-urlencoded'
0 commit comments