diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8bbbd5b..d9c9617 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -111,7 +111,7 @@ "build", ".", "--wheel", - "--sdist", + "--sdist" ], "problemMatcher": [] }, @@ -201,6 +201,7 @@ "dependsOrder": "sequence", "dependsOn": [ "Clean", + //"InstallCryptography", "Security", "Sort Imports", "Format", @@ -223,5 +224,18 @@ ], "problemMatcher": [] }, + { + "label": "InstallCryptography", + "type": "process", + "command": "${config:python.defaultInterpreterPath}", + "args": [ + "-m", + "pip", + "install", + "--upgrade", + "cryptography" + ], + "problemMatcher": [] + }, ] } \ No newline at end of file diff --git a/README.md b/README.md index d91d087..ed9bb44 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ source env/bin/activate # (or env\Scripts\activate on Windows) python3 -m pip install --upgrade pyspartn ``` +**FYI** From `pyspartn` version 1.0.7 onwards, SPARTN decryption functionality is optional. To install without decryption support, use the `--no-deps` argument e.g. ```python3 -m pip install --upgrade pyspartn --no-deps```. + *ยน* On some 32-bit Linux platforms (e.g. Raspberry Pi OS 32), it may be necessary to [install Rust compiler support](https://www.rust-lang.org/tools/install) in order to install the `cryptography` package which `pyspartn` depends on to decrypt SPARTN message payloads. See [cryptography install README](https://github.com/semuconsulting/pyspartn/blob/main/cryptography_installation/README.md). For [Conda](https://docs.conda.io/en/latest/) users, `pyspartn` is also available from [conda-forge](https://github.com/conda-forge/pyspartn-feedstock): diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2f3f760..f6262a0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,9 @@ # pyspartn Release Notes +### RELEASE 1.0.7 + +1. Make SPARTN decryption (and associated `cryptography` library dependencies) an optional feature, to avoid a hard dependency on the `cryptography` library (which can be problematic on some platforms). To install without SPARTN decryption support, use `python3 -m pip install pyspartn --no-deps`. + ### RELEASE 1.0.6 1. Update build configuration and minimum cryptography version - no functional changes. diff --git a/pyproject.toml b/pyproject.toml index 4a0f497..5249720 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ disable = """ [tool.pytest.ini_options] minversion = "7.0" -addopts = "--cov --cov-report html --cov-fail-under 95" +addopts = "--cov --cov-report html --cov-fail-under 94" pythonpath = ["src"] [tool.coverage.run] diff --git a/src/pyspartn/_version.py b/src/pyspartn/_version.py index 328d904..d3df359 100644 --- a/src/pyspartn/_version.py +++ b/src/pyspartn/_version.py @@ -8,4 +8,4 @@ :license: BSD 3-Clause """ -__version__ = "1.0.6" +__version__ = "1.0.7" diff --git a/src/pyspartn/spartnhelpers.py b/src/pyspartn/spartnhelpers.py index 2c349db..55ffb71 100644 --- a/src/pyspartn/spartnhelpers.py +++ b/src/pyspartn/spartnhelpers.py @@ -11,8 +11,12 @@ from datetime import datetime, timedelta, timezone -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + HASCRYPTO = True +except (ImportError, ModuleNotFoundError): + HASCRYPTO = False from pyspartn.exceptions import SPARTNMessageError from pyspartn.spartntypes_core import FL, IN, SPARTN_DATA_FIELDS, TIMEBASE @@ -176,6 +180,9 @@ def encrypt(pt: bytes, key: bytes, iv: bytes, mode: str = "CTR") -> tuple: :rtype: tuple """ + if not HASCRYPTO: # pragma: no-cover + return None, None + if mode == "CTR": cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) else: @@ -201,6 +208,9 @@ def decrypt(ct: bytes, key: bytes, iv: bytes, mode: str = "CTR") -> bytes: :rtype: bytes """ + if not HASCRYPTO: # pragma: no-cover + return None + if mode == "CTR": cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) else: diff --git a/src/pyspartn/spartnmessage.py b/src/pyspartn/spartnmessage.py index 64730c1..c45c138 100644 --- a/src/pyspartn/spartnmessage.py +++ b/src/pyspartn/spartnmessage.py @@ -24,6 +24,7 @@ SPARTNParseError, ) from pyspartn.spartnhelpers import ( + HASCRYPTO, bitsval, convert_timetag, decrypt, @@ -124,9 +125,6 @@ def __init__( self._key = None if key is None else bytes.fromhex(key) self._iv = None - if self._decode and self._key is None: - raise ParameterError("Key must be provided if decoding is enabled") - self._do_attributes() self._immutable = True # once initialised, object is immutable @@ -141,10 +139,19 @@ def _do_attributes(self): # start of framestart self.msgType = bitsval(self._transport, 8, 7) self.nData = bitsval(self._transport, 15, 10) - self.eaf = bitsval(self._transport, 25, 1) + self.eaf = bitsval(self._transport, 25, 1) # 1 = encrypted self.crcType = bitsval(self._transport, 26, 2) self.frameCrc = bitsval(self._transport, 28, 4) + # check if decryption available + if self._decode and self.eaf: + if self._key is None: + raise ParameterError("Key must be provided if decryption is enabled") + if not HASCRYPTO: + raise ParameterError( + "Decryption not available - cryptography library is not installed" + ) + # start of payDesc self.msgSubtype = bitsval(self._transport, 32, 4) self.timeTagtype = bitsval(self._transport, 36, 1) diff --git a/tests/test_static.py b/tests/test_static.py index 060e174..c9074a9 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -12,6 +12,13 @@ import unittest from datetime import datetime, timezone +try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + HASCRYPTO = True +except (ImportError, ModuleNotFoundError): + HASCRYPTO = False + from pyspartn.exceptions import SPARTNMessageError from pyspartn.spartnhelpers import ( att2idx, @@ -114,6 +121,9 @@ def testescapeall(self): self.assertEqual(res, EXPECTED_RESULT) def testdecrypt(self): + if not HASCRYPTO: + return + msg = b"your secret message" key = 0x395C12348D083E53AD0A5AA257C6A741.to_bytes(16, "big") iv = os.urandom(16) diff --git a/tests/test_stream.py b/tests/test_stream.py index 6cbe6d2..312bbbe 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -12,6 +12,13 @@ from datetime import datetime, timezone from logging import ERROR +try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + HASCRYPTO = True +except (ImportError, ModuleNotFoundError): + HASCRYPTO = False + from pyspartn.exceptions import ( SPARTNMessageError, SPARTNParseError, @@ -195,6 +202,9 @@ def testHPACLOGnodecode( def testHPACLOG( self, ): # test decoding of SPARTN HPAC message + if not HASCRYPTO: + return + EXPECTED_RESULT = [ "", "", @@ -253,6 +263,9 @@ def testGADLOGnodecode( def testGADLOG( self, ): # test decoding of SPARTN GAD message + if not HASCRYPTO: + return + EXPECTED_RESULT = [ "", "", @@ -313,6 +326,9 @@ def testOCBLOGnodecode( def testOCBLOG( self, ): # test decoding of SPARTN OCB GPS message + if not HASCRYPTO: + return + EXPECTED_RESULT = ( "", "", @@ -396,7 +412,7 @@ def testnullkeyparse( def testnullkeygen( self, ): # test null decryption key in parse method NB test will fail if MQTTKEY env variable is set - EXPECTED_ERROR = "Key must be provided if decoding is enabled" + EXPECTED_ERROR = "Key must be provided if decryption is enabled" if os.getenv("MQTTKEY", None) is None: with self.assertRaisesRegex(ParameterError, EXPECTED_ERROR): spm = SPARTNMessage( @@ -418,6 +434,9 @@ def testdatastream(self): # test serialize() def testrollover16( self, ): # test decryption of 16-bit timetag dataset containing half day rollover + if not HASCRYPTO: + return + i = 0 with open( os.path.join(self.dirname, "spartn_rollover_only16_20240428235040.log"), @@ -442,6 +461,9 @@ def testrollover16( def testrollover32( self, ): # test decryption of 32-bit & 16-bit timetag dataset containing half day rollover + if not HASCRYPTO: + return + i = 0 with open( os.path.join(self.dirname, "spartn_rollover_32and16_20240428235040.log"), @@ -466,6 +488,9 @@ def testrollover32( def testspartnntrip( self, ): # test decryption of datastream from SPARTN NTRIP caster containing unencrypted messages (eaf=0) + if not HASCRYPTO: + return + EXPECTED_RESULT = [ "", "", @@ -503,6 +528,9 @@ def testspartnntrip( self.assertEqual(i, 10) def testbasedate0(self): # test basedate of TIMEBASE (use tags from data stream) + if not HASCRYPTO: + return + i = e = total = 0 with open( os.path.join(self.dirname, "spartnMIXED.log"), @@ -541,6 +569,9 @@ def testbasedate0(self): # test basedate of TIMEBASE (use tags from data stream self.assertEqual((total, i, e), (422, 418, 4)) def testbasedateNone(self): # test basedate of None (defaults to current time) + if not HASCRYPTO: + return + with open( os.path.join(self.dirname, "spartnMIXED.log"), "rb", @@ -580,6 +611,9 @@ def testbasedateNone(self): # test basedate of None (defaults to current time) self.assertEqual(422, i + e) def testdecryptionerror(self): # test incorrect decryption key + if not HASCRYPTO: + return + EXPECTED_ERROR = "Message type SPARTN-1X-OCB-GPS timetag 33190 not successfully decrypted - check key and basedate" with open( os.path.join(self.dirname, "spartnMIXED.log"), @@ -598,6 +632,28 @@ def testdecryptionerror(self): # test incorrect decryption key pass self.assertTrue(EXPECTED_ERROR in str(context.exception)) + def testnocrypto(self): + if HASCRYPTO: + return + + ERR = "Decryption not available - cryptography library is not installed" + i = 0 + with open( + os.path.join(self.dirname, "spartnntrip_20240430192807.log"), + "rb", + ) as stream: + spr = SPARTNReader( + stream, + quitonerror=ERRRAISE, + decode=True, + key="abcdef1234567890abcdef1234567890", # key is arbitrary for unencrypted messages + basedate=datetime( + 2024, 1, 1, 1, 1, 1 + ), # basedate is arbitrary for unencrypted messages + ) + with self.assertRaisesRegex(ParameterError, ERR): + for raw, parsed in spr: + print(parsed) if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName']