1+ # Copyright (c) Plotsensor Ltd. 2020.
12from .circularbuffer import CircularBufferURL
23from .exceptions import MessageIntegrityError
34from .b64decode import B64Decoder
5+ from typing import List
6+ from enum import Enum
47import hashlib
58import hmac
69
912PAIRS_PER_DEMI = 2
1013BYTES_PER_DEMI = BYTES_PER_PAIRB64 * PAIRS_PER_DEMI
1114
15+ class HashType (Enum ):
16+ MD5 = 1
17+ HMAC_MD5 = 2
1218
1319class Pair :
1420 """
@@ -96,24 +102,47 @@ def readings(self):
96102class PairsURL (CircularBufferURL ):
97103 def __init__ (self , * args , usehmac : bool = False , secretkey : str = None , ** kwargs ):
98104 """
105+ This takes the payload of the linearised buffer, which is a long string of base64 characters. It decodes this
106+ into a list of pairs. The hash (MD5 or HMAC-MD5) is taken and compared with that supplied in the URL by the
107+ encoder. If the hashes match then the decode has been successful. If not, an exception is raised.
99108
100- :param args:
101- :param usehmac:
102- :param secretkey:
103- :param kwargs:
109+ Parameters
110+ ----------
111+ *args
112+ Variable length argument list.
113+ usehmac: bool
114+ True if the hash inside the circular buffer endstop is HMAC-MD5. False if it is MD5.
115+ secretkey: str
116+ HMAC secret key as a string. Normally 16 characters long.
117+ **kwargs
118+ Keyword arguments to be passed to parent class constructors.
104119 """
105120 super ().__init__ (* args , ** kwargs )
106121
107- self .usehmac = usehmac
108- self .secretkey = secretkey
109-
110122 self ._decode_pairs ()
111- self ._verify ()
123+ self ._verify (usehmac , secretkey )
112124
113- def _verify (self ):
125+ def _verify (self , usehmac : bool , secretkey : str ):
114126 """
127+ Calculate a hash from the list of pairs according to the same algorithm used
128+ by the encoder (see :ref:`pairhist_hash`). Besides pairs, data from the status URL parameter
129+ are included. This makes it very unlikely that the same data will be hashed twice, as well as 'protecting'
130+ the status parameter from modification by a 3rd party.
131+
132+ A fragment of the calculated hash is compared with that supplied by the encoder. If the hashes agree then
133+ verification is successful. If not, an exception is raised.
134+
135+ Parameters
136+ ----------
137+ usehmac : bool
138+ True if the hash inside the circular buffer endstop is HMAC-MD5. False if it is MD5.
139+ secretkey : str
140+ HMAC secret key as a string. Normally 16 characters long.
141+
142+ Raises
143+ -------
144+ MessageIntegrityError: If the hash calculated by this decoder does not match the hash provided by the encoder.
115145
116- :return:
117146 """
118147 pairhist = bytearray ()
119148
@@ -132,67 +161,114 @@ def _verify(self):
132161 pairhist .append (self .endmarkerpos & 0xFF )
133162
134163 # Perform message authentication.
135- calcMD5 = self ._gethash (pairhist )
136- urlMD5 = self .urlMD5
164+ calcHash , self . hashtype = self .__class__ . _gethash (pairhist , usehmac , secretkey )
165+ urlHash = self .hash
137166
138167 # Truncate calculated MD5 to the same length as the URL MD5.
139- calcMD5 = calcMD5 [0 :len (urlMD5 )]
168+ calcHash = calcHash [0 :len (urlHash )]
140169
141- if urlMD5 != calcMD5 :
142- raise MessageIntegrityError (calcMD5 , urlMD5 )
170+ if urlHash != calcHash :
171+ raise MessageIntegrityError (calcHash , urlHash )
143172
144173 assert self .npairs == len (self .pairs )
145174
146- def _gethash (self , message ):
175+ @staticmethod
176+ def _gethash (message : bytearray , usehmac : bool , secretkey : str ):
147177 """
178+ Calculates the hash of a message.
179+
180+ Parameters
181+ ----------
182+ message : bytearray
183+ Input data to the hashing algorithm.
184+ usehmac : bool
185+ When True the HMAC-MD5 algorithm is used. Otherwise MD5 is used (not recommended for production).
186+ secretkey : str
187+ HMAC secret key as a string. Normally 16 characters long.
188+
189+ Returns
190+ -------
191+ digest : str
192+ The message hash.
193+ hashtype : HashType
194+ The hash algorithm used.
148195
149- :param message:
150- :return:
151196 """
152- secretkeyba = bytearray (self . secretkey , 'utf8' )
153- if self . usehmac :
197+ secretkeyba = bytearray (secretkey , 'utf8' )
198+ if usehmac :
154199 hmacobj = hmac .new (secretkeyba , message , "md5" )
155200 digest = hmacobj .hexdigest ()
201+ hashtype = HashType .HMAC_MD5
156202 else :
157203 digest = hashlib .md5 (message ).hexdigest ()
158- return digest
204+ hashtype = HashType .MD5
205+ return digest , hashtype
159206
160207 def _decode_pairs (self ):
161208 """
209+ The payload string is converted into a list of 8-byte demis (see :ref:`demi`).
210+
211+ The first demi is the newest; its data have been written to the circular buffer most recently,
212+ so it closest to the left of the endstop. It can contain either one or two pairs.
213+ This is decoded first.
162214
163- :return:
215+ Subsequent (older) demis each contain 2 pairs. These are decoded. The final list of pairs is in
216+ chronological order with the newest first and the oldest last.
164217 """
165218 self .pairs = list ()
166219
167220 # Convert payload string into 8 byte demis.
168- demis = self ._chunkstring (self .payloadstr , BYTES_PER_DEMI )
221+ demis = self ._dividestring (self .payloadstr , BYTES_PER_DEMI )
169222
170- # The newest 8 byte chunk might only contain
171- # 1 valid pair. If so, this is a
172- # partial demi and it is processed first.
173- rem = self .npairs % PAIRS_PER_DEMI
223+ # The newest 8 byte demi might only contain 1 valid pair.
224+ # If so, it is a partial one so it gets processed first.
225+ partial = self .npairs % PAIRS_PER_DEMI
174226 full = int (self .npairs / PAIRS_PER_DEMI )
175227
176- if rem != 0 :
228+ if partial != 0 :
177229 demi = demis .pop ()
178- pair = self ._pairsfromdemi (demi )[1 ]
230+ pair = self ._pairsfromdemi (demi )[1 ] # Only append the oldest pair, for the newest is invalid.
179231 self .pairs .append (pair )
180232
181233 # Process remaining full demis. These all contain 2 pairs.
182234 for i in range (0 , full , 1 ):
183235 demi = demis .pop ()
184- demipairs = self ._pairsfromdemi (demi )
236+ demipairs = self ._pairsfromdemi (demi ) # Append both pairs.
185237 self .pairs .extend (demipairs )
186238
187- def _chunkstring (self , string , n ):
188- return list (string [i :i + n ] for i in range (0 , len (string ), n ))
239+ @staticmethod
240+ def _dividestring (source : str , n : int ):
241+ """
242+
243+ Parameters
244+ ----------
245+ source : str
246+ The string to be divided.
247+ n
248+ The number of characters in each substring.
249+
250+ Returns
251+ -------
252+ A list of substrings, each containing n characters.
189253
190- # Obtain samples from a 4 byte base64 chunk. Chunk should be renamed to demi here.
191- def _pairsfromdemi (self , demi ):
192254 """
255+ return list (source [i :i + n ] for i in range (0 , len (source ), n ))
256+
257+ def _pairsfromdemi (self , demi : str ) -> List [Pair ]:
258+ """
259+ Decode a demi into 2 pairs.
260+
261+ Parameters
262+ ----------
263+ demi : str
264+ A string containing 8 base64 characters.
265+
266+ Returns
267+ -------
268+ A list of 2 pairs:
269+ Element 0 is the oldest pair, decoded from the first 4 demi characters.
270+ Element 1 is the newest pair, decoded from the last 4 demi characters.
193271
194- :param demi:
195- :return:
196272 """
197273 pairs = list ()
198274
@@ -203,6 +279,5 @@ def _pairsfromdemi(self, demi):
203279 pair = Pair .from_b64 (pairb64 )
204280 pairs .append (pair )
205281
206- # Return newest sample first.
207282 pairs .reverse ()
208283 return pairs
0 commit comments