Skip to content

Commit 51bd647

Browse files
committed
Documented the PairsURL class.
1 parent 5caa0d2 commit 51bd647

3 files changed

Lines changed: 115 additions & 40 deletions

File tree

wscodec/decoder/circularbuffer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,10 @@ def _decode_endstop(self):
8080

8181
# Extract the number of samples and the HMAC/MD5 checksum from the endstop.
8282
npairsbytes = hashn[7:9]
83-
md5bytes = hashn[0:7]
83+
hashbytes = hashn[0:7]
8484

8585
self.elapsedmins = int.from_bytes(elapsedbytes, byteorder='little')
8686
self.npairs = unpack(">H", npairsbytes)[0]
87-
self.urlMD5 = md5bytes.hex()
87+
self.hash = hashbytes.hex()
8888

8989

wscodec/decoder/decoderfactory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def decode(secretkey: str,
1919
Parameters
2020
-----------
2121
secretkey: str
22-
HMAC secret key as a string. Normally 16 bytes.
22+
HMAC secret key as a string. Normally 16 characters long.
2323
2424
statb64: str
2525
Value of the URL parameter that holds status information (base64 encoded).

wscodec/decoder/pairs.py

Lines changed: 112 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
# Copyright (c) Plotsensor Ltd. 2020.
12
from .circularbuffer import CircularBufferURL
23
from .exceptions import MessageIntegrityError
34
from .b64decode import B64Decoder
5+
from typing import List
6+
from enum import Enum
47
import hashlib
58
import hmac
69

@@ -9,6 +12,9 @@
912
PAIRS_PER_DEMI = 2
1013
BYTES_PER_DEMI = BYTES_PER_PAIRB64 * PAIRS_PER_DEMI
1114

15+
class HashType(Enum):
16+
MD5 = 1
17+
HMAC_MD5 = 2
1218

1319
class Pair:
1420
"""
@@ -96,24 +102,47 @@ def readings(self):
96102
class 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

Comments
 (0)