dynamic-image-encryption 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. dynamic_image_encryption-0.1.0/LICENSE +21 -0
  2. dynamic_image_encryption-0.1.0/PKG-INFO +153 -0
  3. dynamic_image_encryption-0.1.0/README.md +121 -0
  4. dynamic_image_encryption-0.1.0/pyproject.toml +37 -0
  5. dynamic_image_encryption-0.1.0/setup.cfg +4 -0
  6. dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/PKG-INFO +153 -0
  7. dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/SOURCES.txt +20 -0
  8. dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/dependency_links.txt +1 -0
  9. dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/requires.txt +18 -0
  10. dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/top_level.txt +1 -0
  11. dynamic_image_encryption-0.1.0/src/image_encryption/__init__.py +15 -0
  12. dynamic_image_encryption-0.1.0/src/image_encryption/container.py +62 -0
  13. dynamic_image_encryption-0.1.0/src/image_encryption/core.py +36 -0
  14. dynamic_image_encryption-0.1.0/src/image_encryption/exceptions.py +14 -0
  15. dynamic_image_encryption-0.1.0/src/image_encryption/image.py +104 -0
  16. dynamic_image_encryption-0.1.0/src/image_encryption/key.py +35 -0
  17. dynamic_image_encryption-0.1.0/src/image_encryption/nifti.py +210 -0
  18. dynamic_image_encryption-0.1.0/src/image_encryption/operations.py +58 -0
  19. dynamic_image_encryption-0.1.0/tests/test_core.py +23 -0
  20. dynamic_image_encryption-0.1.0/tests/test_images.py +25 -0
  21. dynamic_image_encryption-0.1.0/tests/test_nifti.py +44 -0
  22. dynamic_image_encryption-0.1.0/tests/test_operations.py +17 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Safouane Akrimi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: dynamic-image-encryption
3
+ Version: 0.1.0
4
+ Summary: Research-oriented dynamic byte-level image and NIfTI encryption library.
5
+ Author-email: Safouane Akrimi <safouaneakrimi@gmail.com>
6
+ License: MIT
7
+ Keywords: image encryption,cryptography,NIfTI,medical imaging,security
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: image
18
+ Requires-Dist: numpy>=1.24; extra == "image"
19
+ Requires-Dist: Pillow>=10.0; extra == "image"
20
+ Provides-Extra: nifti
21
+ Requires-Dist: numpy>=1.24; extra == "nifti"
22
+ Requires-Dist: nibabel>=5.0; extra == "nifti"
23
+ Provides-Extra: all
24
+ Requires-Dist: numpy>=1.24; extra == "all"
25
+ Requires-Dist: Pillow>=10.0; extra == "all"
26
+ Requires-Dist: nibabel>=5.0; extra == "all"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: twine>=5.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # Dynamic Image Encryption
34
+
35
+ Research-oriented Python implementation of a dynamic byte-level encryption
36
+ algorithm using modular addition, modular subtraction, XOR and circular
37
+ 8-bit rotation.
38
+
39
+ For each byte:
40
+
41
+ ```text
42
+ S_i = K_i mod 4
43
+ ```
44
+
45
+ Then:
46
+
47
+ ```text
48
+ S=0 -> C=(P+K) mod 256
49
+ S=1 -> C=(P-K) mod 256
50
+ S=2 -> C=P XOR K
51
+ S=3 -> C=ROL(P,K mod 8)
52
+ ```
53
+
54
+ Decryption applies the corresponding inverse operation.
55
+
56
+ ## Security note
57
+
58
+ This package is intended for research, experimentation and academic
59
+ evaluation. It is not a replacement for established authenticated
60
+ encryption schemes such as AES-GCM. No authentication/integrity mechanism
61
+ is provided, and the key has the same length as the encrypted byte sequence.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install "dynamic-image-encryption[all]"
67
+ ```
68
+
69
+ ## Byte API
70
+
71
+ ```python
72
+ from image_encryption import encrypt, decrypt
73
+
74
+ ciphertext, key = encrypt(b"Hello")
75
+ plaintext = decrypt(ciphertext, key)
76
+
77
+ assert plaintext == b"Hello"
78
+ ```
79
+
80
+ ## Image API
81
+
82
+ ```python
83
+ from image_encryption import encrypt_image, decrypt_image, save_key
84
+
85
+ key = encrypt_image("input.png", "encrypted.ienc")
86
+ save_key(key, "image.key")
87
+ decrypt_image("encrypted.ienc", key, "decrypted.png")
88
+ ```
89
+
90
+ The image API works on decoded pixel bytes. Native Pillow modes are L, RGB
91
+ and RGBA; other modes are converted to RGB. The decrypted conventional image
92
+ is written as PNG.
93
+
94
+ ## NIfTI API
95
+
96
+ NIfTI voxel values are not reduced modulo 256. The raw bytes representing
97
+ each voxel are encrypted. Thus an int16 voxel such as 7899 is encrypted as
98
+ its two dtype/endianness-dependent bytes.
99
+
100
+ ```python
101
+ from image_encryption import encrypt_nifti, decrypt_nifti, save_key
102
+
103
+ key = encrypt_nifti("brain.nii.gz", "brain.ienc")
104
+ save_key(key, "brain.key")
105
+ decrypt_nifti("brain.ienc", key, "brain.decrypted.nii.gz")
106
+ ```
107
+
108
+ The implementation stores the raw voxel dtype/shape, affine, NIfTI header,
109
+ extensions and scaling information needed for reconstruction.
110
+
111
+ ## Tests
112
+
113
+ ```bash
114
+ pip install -e ".[all,dev]"
115
+ pytest -q
116
+ ```
117
+
118
+ ## Build
119
+
120
+ Change the PyPI `name` in `pyproject.toml` if the chosen name is already
121
+ registered.
122
+
123
+ ```bash
124
+ python -m build
125
+ python -m twine check dist/*
126
+ python -m twine upload --repository testpypi dist/*
127
+ python -m twine upload dist/*
128
+ ```
129
+
130
+ ## Structure
131
+
132
+ ```text
133
+ image-encryption/
134
+ ├── pyproject.toml
135
+ ├── README.md
136
+ ├── LICENSE
137
+ ├── .gitignore
138
+ ├── src/image_encryption/
139
+ │ ├── __init__.py
140
+ │ ├── core.py
141
+ │ ├── key.py
142
+ │ ├── operations.py
143
+ │ ├── container.py
144
+ │ ├── image.py
145
+ │ ├── nifti.py
146
+ │ └── exceptions.py
147
+ ├── tests/
148
+ └── examples/
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT.
@@ -0,0 +1,121 @@
1
+ # Dynamic Image Encryption
2
+
3
+ Research-oriented Python implementation of a dynamic byte-level encryption
4
+ algorithm using modular addition, modular subtraction, XOR and circular
5
+ 8-bit rotation.
6
+
7
+ For each byte:
8
+
9
+ ```text
10
+ S_i = K_i mod 4
11
+ ```
12
+
13
+ Then:
14
+
15
+ ```text
16
+ S=0 -> C=(P+K) mod 256
17
+ S=1 -> C=(P-K) mod 256
18
+ S=2 -> C=P XOR K
19
+ S=3 -> C=ROL(P,K mod 8)
20
+ ```
21
+
22
+ Decryption applies the corresponding inverse operation.
23
+
24
+ ## Security note
25
+
26
+ This package is intended for research, experimentation and academic
27
+ evaluation. It is not a replacement for established authenticated
28
+ encryption schemes such as AES-GCM. No authentication/integrity mechanism
29
+ is provided, and the key has the same length as the encrypted byte sequence.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install "dynamic-image-encryption[all]"
35
+ ```
36
+
37
+ ## Byte API
38
+
39
+ ```python
40
+ from image_encryption import encrypt, decrypt
41
+
42
+ ciphertext, key = encrypt(b"Hello")
43
+ plaintext = decrypt(ciphertext, key)
44
+
45
+ assert plaintext == b"Hello"
46
+ ```
47
+
48
+ ## Image API
49
+
50
+ ```python
51
+ from image_encryption import encrypt_image, decrypt_image, save_key
52
+
53
+ key = encrypt_image("input.png", "encrypted.ienc")
54
+ save_key(key, "image.key")
55
+ decrypt_image("encrypted.ienc", key, "decrypted.png")
56
+ ```
57
+
58
+ The image API works on decoded pixel bytes. Native Pillow modes are L, RGB
59
+ and RGBA; other modes are converted to RGB. The decrypted conventional image
60
+ is written as PNG.
61
+
62
+ ## NIfTI API
63
+
64
+ NIfTI voxel values are not reduced modulo 256. The raw bytes representing
65
+ each voxel are encrypted. Thus an int16 voxel such as 7899 is encrypted as
66
+ its two dtype/endianness-dependent bytes.
67
+
68
+ ```python
69
+ from image_encryption import encrypt_nifti, decrypt_nifti, save_key
70
+
71
+ key = encrypt_nifti("brain.nii.gz", "brain.ienc")
72
+ save_key(key, "brain.key")
73
+ decrypt_nifti("brain.ienc", key, "brain.decrypted.nii.gz")
74
+ ```
75
+
76
+ The implementation stores the raw voxel dtype/shape, affine, NIfTI header,
77
+ extensions and scaling information needed for reconstruction.
78
+
79
+ ## Tests
80
+
81
+ ```bash
82
+ pip install -e ".[all,dev]"
83
+ pytest -q
84
+ ```
85
+
86
+ ## Build
87
+
88
+ Change the PyPI `name` in `pyproject.toml` if the chosen name is already
89
+ registered.
90
+
91
+ ```bash
92
+ python -m build
93
+ python -m twine check dist/*
94
+ python -m twine upload --repository testpypi dist/*
95
+ python -m twine upload dist/*
96
+ ```
97
+
98
+ ## Structure
99
+
100
+ ```text
101
+ image-encryption/
102
+ ├── pyproject.toml
103
+ ├── README.md
104
+ ├── LICENSE
105
+ ├── .gitignore
106
+ ├── src/image_encryption/
107
+ │ ├── __init__.py
108
+ │ ├── core.py
109
+ │ ├── key.py
110
+ │ ├── operations.py
111
+ │ ├── container.py
112
+ │ ├── image.py
113
+ │ ├── nifti.py
114
+ │ └── exceptions.py
115
+ ├── tests/
116
+ └── examples/
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dynamic-image-encryption"
7
+ version = "0.1.0"
8
+ description = "Research-oriented dynamic byte-level image and NIfTI encryption library."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Safouane Akrimi", email="safouaneakrimi@gmail.com" }]
13
+ keywords = ["image encryption", "cryptography", "NIfTI", "medical imaging", "security"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ image = ["numpy>=1.24", "Pillow>=10.0"]
26
+ nifti = ["numpy>=1.24", "nibabel>=5.0"]
27
+ all = ["numpy>=1.24", "Pillow>=10.0", "nibabel>=5.0"]
28
+ dev = ["pytest>=8.0", "build>=1.2", "twine>=5.0"]
29
+
30
+ [tool.setuptools]
31
+ package-dir = {"" = "src"}
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: dynamic-image-encryption
3
+ Version: 0.1.0
4
+ Summary: Research-oriented dynamic byte-level image and NIfTI encryption library.
5
+ Author-email: Safouane Akrimi <safouaneakrimi@gmail.com>
6
+ License: MIT
7
+ Keywords: image encryption,cryptography,NIfTI,medical imaging,security
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: image
18
+ Requires-Dist: numpy>=1.24; extra == "image"
19
+ Requires-Dist: Pillow>=10.0; extra == "image"
20
+ Provides-Extra: nifti
21
+ Requires-Dist: numpy>=1.24; extra == "nifti"
22
+ Requires-Dist: nibabel>=5.0; extra == "nifti"
23
+ Provides-Extra: all
24
+ Requires-Dist: numpy>=1.24; extra == "all"
25
+ Requires-Dist: Pillow>=10.0; extra == "all"
26
+ Requires-Dist: nibabel>=5.0; extra == "all"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: twine>=5.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # Dynamic Image Encryption
34
+
35
+ Research-oriented Python implementation of a dynamic byte-level encryption
36
+ algorithm using modular addition, modular subtraction, XOR and circular
37
+ 8-bit rotation.
38
+
39
+ For each byte:
40
+
41
+ ```text
42
+ S_i = K_i mod 4
43
+ ```
44
+
45
+ Then:
46
+
47
+ ```text
48
+ S=0 -> C=(P+K) mod 256
49
+ S=1 -> C=(P-K) mod 256
50
+ S=2 -> C=P XOR K
51
+ S=3 -> C=ROL(P,K mod 8)
52
+ ```
53
+
54
+ Decryption applies the corresponding inverse operation.
55
+
56
+ ## Security note
57
+
58
+ This package is intended for research, experimentation and academic
59
+ evaluation. It is not a replacement for established authenticated
60
+ encryption schemes such as AES-GCM. No authentication/integrity mechanism
61
+ is provided, and the key has the same length as the encrypted byte sequence.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install "dynamic-image-encryption[all]"
67
+ ```
68
+
69
+ ## Byte API
70
+
71
+ ```python
72
+ from image_encryption import encrypt, decrypt
73
+
74
+ ciphertext, key = encrypt(b"Hello")
75
+ plaintext = decrypt(ciphertext, key)
76
+
77
+ assert plaintext == b"Hello"
78
+ ```
79
+
80
+ ## Image API
81
+
82
+ ```python
83
+ from image_encryption import encrypt_image, decrypt_image, save_key
84
+
85
+ key = encrypt_image("input.png", "encrypted.ienc")
86
+ save_key(key, "image.key")
87
+ decrypt_image("encrypted.ienc", key, "decrypted.png")
88
+ ```
89
+
90
+ The image API works on decoded pixel bytes. Native Pillow modes are L, RGB
91
+ and RGBA; other modes are converted to RGB. The decrypted conventional image
92
+ is written as PNG.
93
+
94
+ ## NIfTI API
95
+
96
+ NIfTI voxel values are not reduced modulo 256. The raw bytes representing
97
+ each voxel are encrypted. Thus an int16 voxel such as 7899 is encrypted as
98
+ its two dtype/endianness-dependent bytes.
99
+
100
+ ```python
101
+ from image_encryption import encrypt_nifti, decrypt_nifti, save_key
102
+
103
+ key = encrypt_nifti("brain.nii.gz", "brain.ienc")
104
+ save_key(key, "brain.key")
105
+ decrypt_nifti("brain.ienc", key, "brain.decrypted.nii.gz")
106
+ ```
107
+
108
+ The implementation stores the raw voxel dtype/shape, affine, NIfTI header,
109
+ extensions and scaling information needed for reconstruction.
110
+
111
+ ## Tests
112
+
113
+ ```bash
114
+ pip install -e ".[all,dev]"
115
+ pytest -q
116
+ ```
117
+
118
+ ## Build
119
+
120
+ Change the PyPI `name` in `pyproject.toml` if the chosen name is already
121
+ registered.
122
+
123
+ ```bash
124
+ python -m build
125
+ python -m twine check dist/*
126
+ python -m twine upload --repository testpypi dist/*
127
+ python -m twine upload dist/*
128
+ ```
129
+
130
+ ## Structure
131
+
132
+ ```text
133
+ image-encryption/
134
+ ├── pyproject.toml
135
+ ├── README.md
136
+ ├── LICENSE
137
+ ├── .gitignore
138
+ ├── src/image_encryption/
139
+ │ ├── __init__.py
140
+ │ ├── core.py
141
+ │ ├── key.py
142
+ │ ├── operations.py
143
+ │ ├── container.py
144
+ │ ├── image.py
145
+ │ ├── nifti.py
146
+ │ └── exceptions.py
147
+ ├── tests/
148
+ └── examples/
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT.
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/dynamic_image_encryption.egg-info/PKG-INFO
5
+ src/dynamic_image_encryption.egg-info/SOURCES.txt
6
+ src/dynamic_image_encryption.egg-info/dependency_links.txt
7
+ src/dynamic_image_encryption.egg-info/requires.txt
8
+ src/dynamic_image_encryption.egg-info/top_level.txt
9
+ src/image_encryption/__init__.py
10
+ src/image_encryption/container.py
11
+ src/image_encryption/core.py
12
+ src/image_encryption/exceptions.py
13
+ src/image_encryption/image.py
14
+ src/image_encryption/key.py
15
+ src/image_encryption/nifti.py
16
+ src/image_encryption/operations.py
17
+ tests/test_core.py
18
+ tests/test_images.py
19
+ tests/test_nifti.py
20
+ tests/test_operations.py
@@ -0,0 +1,18 @@
1
+
2
+ [all]
3
+ numpy>=1.24
4
+ Pillow>=10.0
5
+ nibabel>=5.0
6
+
7
+ [dev]
8
+ pytest>=8.0
9
+ build>=1.2
10
+ twine>=5.0
11
+
12
+ [image]
13
+ numpy>=1.24
14
+ Pillow>=10.0
15
+
16
+ [nifti]
17
+ numpy>=1.24
18
+ nibabel>=5.0
@@ -0,0 +1,15 @@
1
+ from .core import decrypt, decrypt_bytes, encrypt, encrypt_bytes
2
+ from .exceptions import ImageEncryptionError, InvalidContainerError, InvalidKeyError, UnsupportedFormatError
3
+ from .image import decrypt_image, encrypt_image
4
+ from .key import generate_key, load_key, save_key
5
+ from .nifti import decrypt_nifti, encrypt_nifti
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "encrypt", "decrypt", "encrypt_bytes", "decrypt_bytes",
11
+ "encrypt_image", "decrypt_image", "encrypt_nifti", "decrypt_nifti",
12
+ "generate_key", "save_key", "load_key",
13
+ "ImageEncryptionError", "InvalidContainerError",
14
+ "InvalidKeyError", "UnsupportedFormatError",
15
+ ]
@@ -0,0 +1,62 @@
1
+ import json
2
+ import struct
3
+ from pathlib import Path
4
+
5
+ from .exceptions import InvalidContainerError
6
+
7
+ MAGIC = b"IENC"
8
+ VERSION = 1
9
+ _MAX_METADATA = 16 * 1024 * 1024
10
+ _PREFIX = struct.Struct(">4sBQ")
11
+
12
+
13
+ def pack_container(metadata: dict, ciphertext: bytes) -> bytes:
14
+ if not isinstance(metadata, dict):
15
+ raise TypeError("metadata must be a dictionary")
16
+ ciphertext = bytes(ciphertext)
17
+ metadata_bytes = json.dumps(
18
+ metadata, ensure_ascii=False, separators=(",", ":"), sort_keys=True
19
+ ).encode("utf-8")
20
+ if len(metadata_bytes) > _MAX_METADATA:
21
+ raise InvalidContainerError("metadata is too large")
22
+ return _PREFIX.pack(MAGIC, VERSION, len(metadata_bytes)) + metadata_bytes + ciphertext
23
+
24
+
25
+ def unpack_container(raw: bytes):
26
+ raw = bytes(raw)
27
+ if len(raw) < _PREFIX.size:
28
+ raise InvalidContainerError("container is too small")
29
+
30
+ magic, version, metadata_length = _PREFIX.unpack_from(raw)
31
+ if magic != MAGIC:
32
+ raise InvalidContainerError("invalid container magic")
33
+ if version != VERSION:
34
+ raise InvalidContainerError(f"unsupported container version: {version}")
35
+
36
+ start = _PREFIX.size
37
+ end = start + metadata_length
38
+ if end > len(raw):
39
+ raise InvalidContainerError("metadata extends beyond container")
40
+ if metadata_length > _MAX_METADATA:
41
+ raise InvalidContainerError("metadata is too large")
42
+
43
+ try:
44
+ metadata = json.loads(raw[start:end].decode("utf-8"))
45
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
46
+ raise InvalidContainerError("invalid metadata JSON") from exc
47
+
48
+ if not isinstance(metadata, dict):
49
+ raise InvalidContainerError("metadata must be an object")
50
+
51
+ return metadata, raw[end:]
52
+
53
+
54
+ def write_container(path, metadata, ciphertext):
55
+ output = Path(path)
56
+ output.parent.mkdir(parents=True, exist_ok=True)
57
+ output.write_bytes(pack_container(metadata, ciphertext))
58
+ return output
59
+
60
+
61
+ def read_container(path):
62
+ return unpack_container(Path(path).read_bytes())
@@ -0,0 +1,36 @@
1
+ from .key import generate_key, validate_key
2
+ from .operations import decrypt_byte, encrypt_byte
3
+
4
+
5
+ def _as_bytes(data, name="data") -> bytes:
6
+ if not isinstance(data, (bytes, bytearray, memoryview)):
7
+ raise TypeError(f"{name} must be bytes-like")
8
+ return bytes(data)
9
+
10
+
11
+ def encrypt_bytes(data, key) -> bytes:
12
+ plaintext = _as_bytes(data)
13
+ key_bytes = validate_key(key, len(plaintext))
14
+ ciphertext = bytearray(len(plaintext))
15
+ for i, (p, k) in enumerate(zip(plaintext, key_bytes)):
16
+ ciphertext[i] = encrypt_byte(p, k)
17
+ return bytes(ciphertext)
18
+
19
+
20
+ def decrypt_bytes(data, key) -> bytes:
21
+ ciphertext = _as_bytes(data)
22
+ key_bytes = validate_key(key, len(ciphertext))
23
+ plaintext = bytearray(len(ciphertext))
24
+ for i, (c, k) in enumerate(zip(ciphertext, key_bytes)):
25
+ plaintext[i] = decrypt_byte(c, k)
26
+ return bytes(plaintext)
27
+
28
+
29
+ def encrypt(data, key=None):
30
+ plaintext = _as_bytes(data)
31
+ key_bytes = generate_key(len(plaintext)) if key is None else validate_key(key, len(plaintext))
32
+ return encrypt_bytes(plaintext, key_bytes), key_bytes
33
+
34
+
35
+ def decrypt(data, key) -> bytes:
36
+ return decrypt_bytes(data, key)
@@ -0,0 +1,14 @@
1
+ class ImageEncryptionError(Exception):
2
+ """Base exception."""
3
+
4
+
5
+ class InvalidKeyError(ImageEncryptionError):
6
+ """Invalid or incorrectly sized key."""
7
+
8
+
9
+ class InvalidContainerError(ImageEncryptionError):
10
+ """Malformed or unsupported encrypted container."""
11
+
12
+
13
+ class UnsupportedFormatError(ImageEncryptionError):
14
+ """Unsupported input/output format."""
@@ -0,0 +1,104 @@
1
+ from pathlib import Path
2
+ import math
3
+
4
+ from .container import read_container, write_container
5
+ from .core import decrypt_bytes, encrypt_bytes
6
+ from .exceptions import InvalidContainerError, UnsupportedFormatError
7
+ from .key import generate_key, validate_key
8
+
9
+ _SUPPORTED_MODES = {"L", "RGB", "RGBA"}
10
+
11
+
12
+ def _deps():
13
+ try:
14
+ import numpy as np
15
+ from PIL import Image
16
+ except ImportError as exc:
17
+ raise ImportError(
18
+ "Install image support with: pip install dynamic-image-encryption[image]"
19
+ ) from exc
20
+ return np, Image
21
+
22
+
23
+ def _default_decrypted_path(path):
24
+ name = path.name[:-5] if path.name.endswith(".ienc") else path.stem
25
+ return path.with_name(name + ".decrypted.png")
26
+
27
+
28
+ def encrypt_image(input_path, output_path=None, key=None):
29
+ np, Image = _deps()
30
+ source = Path(input_path)
31
+ if not source.is_file():
32
+ raise FileNotFoundError(source)
33
+
34
+ with Image.open(source) as image:
35
+ source_mode = image.mode
36
+ source_format = image.format or source.suffix.lstrip(".").upper()
37
+ working = image.copy() if image.mode in _SUPPORTED_MODES else image.convert("RGB")
38
+
39
+ array = np.ascontiguousarray(np.asarray(working, dtype=np.uint8))
40
+ mode = working.mode
41
+
42
+ plaintext = array.tobytes(order="C")
43
+ key_bytes = generate_key(len(plaintext)) if key is None else validate_key(key, len(plaintext))
44
+ ciphertext = encrypt_bytes(plaintext, key_bytes)
45
+
46
+ metadata = {
47
+ "kind": "image",
48
+ "container_version": 1,
49
+ "source_format": source_format,
50
+ "source_mode": source_mode,
51
+ "mode": mode,
52
+ "shape": list(array.shape),
53
+ "dtype": array.dtype.str,
54
+ }
55
+
56
+ output = Path(output_path) if output_path is not None else Path(str(source) + ".ienc")
57
+ write_container(output, metadata, ciphertext)
58
+ return key_bytes
59
+
60
+
61
+ def decrypt_image(input_path, key, output_path=None):
62
+ np, Image = _deps()
63
+ source = Path(input_path)
64
+ metadata, ciphertext = read_container(source)
65
+
66
+ if metadata.get("kind") != "image":
67
+ raise InvalidContainerError("container does not contain an image")
68
+
69
+ mode = metadata.get("mode")
70
+ shape = metadata.get("shape")
71
+ dtype_text = metadata.get("dtype")
72
+
73
+ if mode not in _SUPPORTED_MODES:
74
+ raise InvalidContainerError(f"unsupported stored image mode: {mode}")
75
+ if not isinstance(shape, list) or not all(isinstance(x, int) and x >= 0 for x in shape):
76
+ raise InvalidContainerError("invalid image shape")
77
+
78
+ try:
79
+ dtype = np.dtype(dtype_text)
80
+ except (TypeError, ValueError) as exc:
81
+ raise InvalidContainerError("invalid stored dtype") from exc
82
+
83
+ if dtype != np.dtype(np.uint8):
84
+ raise InvalidContainerError("stored image dtype must be uint8")
85
+
86
+ dimensions = 2 if mode == "L" else 3
87
+ if len(shape) != dimensions:
88
+ raise InvalidContainerError("image shape does not match mode")
89
+
90
+ expected = math.prod(shape) * dtype.itemsize
91
+ if len(ciphertext) != expected:
92
+ raise InvalidContainerError("ciphertext length does not match image data")
93
+
94
+ plaintext = decrypt_bytes(ciphertext, key)
95
+ array = np.frombuffer(plaintext, dtype=dtype).reshape(tuple(shape)).copy()
96
+ image = Image.fromarray(array)
97
+
98
+ output = Path(output_path) if output_path is not None else _default_decrypted_path(source)
99
+ if output.suffix.lower() != ".png":
100
+ raise UnsupportedFormatError("decrypted conventional images must use a .png output path")
101
+
102
+ output.parent.mkdir(parents=True, exist_ok=True)
103
+ image.save(output, format="PNG")
104
+ return output
@@ -0,0 +1,35 @@
1
+ from pathlib import Path
2
+ import secrets
3
+
4
+ from .exceptions import InvalidKeyError
5
+
6
+
7
+ def generate_key(length: int) -> bytes:
8
+ if isinstance(length, bool) or not isinstance(length, int):
9
+ raise TypeError("length must be an integer")
10
+ if length < 0:
11
+ raise ValueError("length cannot be negative")
12
+ return secrets.token_bytes(length)
13
+
14
+
15
+ def validate_key(key, expected_length: int | None = None) -> bytes:
16
+ if not isinstance(key, (bytes, bytearray, memoryview)):
17
+ raise InvalidKeyError("key must be bytes-like")
18
+ key_bytes = bytes(key)
19
+ if expected_length is not None and len(key_bytes) != expected_length:
20
+ raise InvalidKeyError(
21
+ f"key length is {len(key_bytes)}, expected {expected_length}"
22
+ )
23
+ return key_bytes
24
+
25
+
26
+ def save_key(key, path) -> Path:
27
+ key_bytes = validate_key(key)
28
+ output = Path(path)
29
+ output.parent.mkdir(parents=True, exist_ok=True)
30
+ output.write_bytes(key_bytes)
31
+ return output
32
+
33
+
34
+ def load_key(path) -> bytes:
35
+ return Path(path).read_bytes()
@@ -0,0 +1,210 @@
1
+ from pathlib import Path
2
+ import base64
3
+ import io
4
+ import math
5
+
6
+ from .container import read_container, write_container
7
+ from .core import decrypt_bytes, encrypt_bytes
8
+ from .exceptions import InvalidContainerError, UnsupportedFormatError
9
+ from .key import generate_key, validate_key
10
+
11
+
12
+ def _deps():
13
+ try:
14
+ import nibabel as nib
15
+ import numpy as np
16
+ except ImportError as exc:
17
+ raise ImportError(
18
+ "Install NIfTI support with: pip install dynamic-image-encryption[nifti]"
19
+ ) from exc
20
+ return nib, np
21
+
22
+
23
+ def _raw_data(image):
24
+ dataobj = image.dataobj
25
+ getter = getattr(dataobj, "get_unscaled", None)
26
+ return getter() if callable(getter) else dataobj
27
+
28
+
29
+ def _scaling(image):
30
+ dataobj = image.dataobj
31
+ slope = getattr(dataobj, "slope", None)
32
+ inter = getattr(dataobj, "inter", None)
33
+
34
+ if slope is None or inter is None:
35
+ try:
36
+ slope = float(image.header["scl_slope"])
37
+ inter = float(image.header["scl_inter"])
38
+ except Exception:
39
+ return None
40
+
41
+ try:
42
+ slope, inter = float(slope), float(inter)
43
+ except (TypeError, ValueError):
44
+ return None
45
+
46
+ if not (slope == slope and inter == inter):
47
+ return None
48
+
49
+ return {"slope": slope, "inter": inter}
50
+
51
+
52
+ def _serialize_extensions(header):
53
+ result = []
54
+ for extension in header.extensions:
55
+ content = extension.get_content()
56
+ content = content.encode("utf-8") if isinstance(content, str) else bytes(content)
57
+ result.append({
58
+ "code": int(extension.get_code()),
59
+ "content": base64.b64encode(content).decode("ascii"),
60
+ })
61
+ return result
62
+
63
+
64
+ def _restore_extensions(header, extensions):
65
+ import nibabel as nib
66
+ for item in extensions:
67
+ try:
68
+ code = int(item["code"])
69
+ content = base64.b64decode(item["content"], validate=True)
70
+ except Exception as exc:
71
+ raise InvalidContainerError("invalid NIfTI extension metadata") from exc
72
+ header.extensions.append(nib.nifti1.Nifti1Extension(code, content))
73
+
74
+
75
+ def _header_class(nib, name):
76
+ if name == "Nifti1Header":
77
+ return nib.Nifti1Header
78
+ if name == "Nifti2Header":
79
+ return nib.Nifti2Header
80
+ raise InvalidContainerError(f"unsupported NIfTI header class: {name}")
81
+
82
+
83
+ def _image_class(nib, name):
84
+ if name == "Nifti1Image":
85
+ return nib.Nifti1Image
86
+ if name == "Nifti2Image":
87
+ return nib.Nifti2Image
88
+ raise InvalidContainerError(f"unsupported NIfTI image class: {name}")
89
+
90
+
91
+ def _default_decrypted_path(path):
92
+ name = path.name[:-5] if path.name.endswith(".ienc") else path.name
93
+ if name.endswith(".nii.gz"):
94
+ stem = name[:-7]
95
+ elif name.endswith(".nii"):
96
+ stem = name[:-4]
97
+ else:
98
+ stem = Path(name).stem
99
+ return path.with_name(stem + ".decrypted.nii.gz")
100
+
101
+
102
+ def encrypt_nifti(input_path, output_path=None, key=None):
103
+ nib, np = _deps()
104
+ source = Path(input_path)
105
+
106
+ if not source.is_file():
107
+ raise FileNotFoundError(source)
108
+ if not (source.name.lower().endswith(".nii") or source.name.lower().endswith(".nii.gz")):
109
+ raise UnsupportedFormatError("input must be .nii or .nii.gz")
110
+
111
+ image = nib.load(str(source))
112
+ if not isinstance(image, (nib.Nifti1Image, nib.Nifti2Image)):
113
+ raise UnsupportedFormatError("only NIfTI-1 and NIfTI-2 are supported")
114
+
115
+ data = np.ascontiguousarray(_raw_data(image))
116
+ if data.dtype.hasobject:
117
+ raise UnsupportedFormatError("object voxel dtype is not supported")
118
+
119
+ plaintext = data.tobytes(order="C")
120
+ key_bytes = generate_key(len(plaintext)) if key is None else validate_key(key, len(plaintext))
121
+ ciphertext = encrypt_bytes(plaintext, key_bytes)
122
+
123
+ affine = image.affine
124
+ if affine is None:
125
+ affine = np.eye(4, dtype=np.float64)
126
+
127
+ metadata = {
128
+ "kind": "nifti",
129
+ "container_version": 1,
130
+ "image_class": type(image).__name__,
131
+ "header_class": type(image.header).__name__,
132
+ "header_b64": base64.b64encode(image.header.binaryblock).decode("ascii"),
133
+ "extensions": _serialize_extensions(image.header),
134
+ "affine": np.asarray(affine, dtype=np.float64).tolist(),
135
+ "shape": list(data.shape),
136
+ "dtype": data.dtype.str,
137
+ "original_filename": source.name,
138
+ }
139
+
140
+ scaling = _scaling(image)
141
+ if scaling is not None:
142
+ metadata["scaling"] = scaling
143
+
144
+ output = Path(output_path) if output_path is not None else Path(str(source) + ".ienc")
145
+ write_container(output, metadata, ciphertext)
146
+ return key_bytes
147
+
148
+
149
+ def decrypt_nifti(input_path, key, output_path=None):
150
+ nib, np = _deps()
151
+ source = Path(input_path)
152
+ metadata, ciphertext = read_container(source)
153
+
154
+ if metadata.get("kind") != "nifti":
155
+ raise InvalidContainerError("container does not contain NIfTI data")
156
+
157
+ shape = metadata.get("shape")
158
+ if not isinstance(shape, list) or not all(isinstance(x, int) and x >= 0 for x in shape):
159
+ raise InvalidContainerError("invalid NIfTI shape")
160
+
161
+ try:
162
+ dtype = np.dtype(metadata["dtype"])
163
+ except (TypeError, ValueError, KeyError) as exc:
164
+ raise InvalidContainerError("invalid NIfTI dtype") from exc
165
+
166
+ expected = math.prod(shape) * dtype.itemsize
167
+ if len(ciphertext) != expected:
168
+ raise InvalidContainerError("ciphertext length does not match voxel data")
169
+
170
+ plaintext = decrypt_bytes(ciphertext, key)
171
+ data = np.frombuffer(plaintext, dtype=dtype).reshape(tuple(shape)).copy()
172
+
173
+ try:
174
+ header_bytes = base64.b64decode(metadata["header_b64"], validate=True)
175
+ except Exception as exc:
176
+ raise InvalidContainerError("invalid NIfTI header data") from exc
177
+
178
+ header_cls = _header_class(nib, metadata.get("header_class"))
179
+ try:
180
+ header = header_cls.from_fileobj(io.BytesIO(header_bytes))
181
+ except Exception as exc:
182
+ raise InvalidContainerError("could not reconstruct NIfTI header") from exc
183
+
184
+ scaling = metadata.get("scaling")
185
+ if scaling is not None:
186
+ try:
187
+ header["scl_slope"] = float(scaling["slope"])
188
+ header["scl_inter"] = float(scaling["inter"])
189
+ except Exception as exc:
190
+ raise InvalidContainerError("invalid NIfTI scaling metadata") from exc
191
+
192
+ affine = np.asarray(metadata.get("affine"), dtype=np.float64)
193
+ if affine.shape != (4, 4):
194
+ raise InvalidContainerError("NIfTI affine must be 4x4")
195
+
196
+ image_cls = _image_class(nib, metadata.get("image_class"))
197
+
198
+ try:
199
+ image = image_cls(data, affine, header=header)
200
+ _restore_extensions(image.header, metadata.get("extensions", []))
201
+ except Exception as exc:
202
+ raise InvalidContainerError("could not reconstruct NIfTI image") from exc
203
+
204
+ output = Path(output_path) if output_path is not None else _default_decrypted_path(source)
205
+ if not (output.name.lower().endswith(".nii") or output.name.lower().endswith(".nii.gz")):
206
+ raise UnsupportedFormatError("NIfTI output must end with .nii or .nii.gz")
207
+
208
+ output.parent.mkdir(parents=True, exist_ok=True)
209
+ nib.save(image, str(output))
210
+ return output
@@ -0,0 +1,58 @@
1
+ def _check_byte(value: int, name: str) -> None:
2
+ if isinstance(value, bool) or not isinstance(value, int):
3
+ raise TypeError(f"{name} must be an integer")
4
+ if not 0 <= value <= 255:
5
+ raise ValueError(f"{name} must be in the range 0..255")
6
+
7
+
8
+ def rotate_left(value: int, shift: int) -> int:
9
+ _check_byte(value, "value")
10
+ if isinstance(shift, bool) or not isinstance(shift, int):
11
+ raise TypeError("shift must be an integer")
12
+ shift %= 8
13
+ if shift == 0:
14
+ return value
15
+ return ((value << shift) | (value >> (8 - shift))) & 0xFF
16
+
17
+
18
+ def rotate_right(value: int, shift: int) -> int:
19
+ _check_byte(value, "value")
20
+ if isinstance(shift, bool) or not isinstance(shift, int):
21
+ raise TypeError("shift must be an integer")
22
+ shift %= 8
23
+ if shift == 0:
24
+ return value
25
+ return ((value >> shift) | (value << (8 - shift))) & 0xFF
26
+
27
+
28
+ def selector(key_byte: int) -> int:
29
+ _check_byte(key_byte, "key_byte")
30
+ return key_byte % 4
31
+
32
+
33
+ def encrypt_byte(plain_byte: int, key_byte: int) -> int:
34
+ _check_byte(plain_byte, "plain_byte")
35
+ _check_byte(key_byte, "key_byte")
36
+ s = selector(key_byte)
37
+
38
+ if s == 0:
39
+ return (plain_byte + key_byte) % 256
40
+ if s == 1:
41
+ return (plain_byte - key_byte) % 256
42
+ if s == 2:
43
+ return plain_byte ^ key_byte
44
+ return rotate_left(plain_byte, key_byte % 8)
45
+
46
+
47
+ def decrypt_byte(cipher_byte: int, key_byte: int) -> int:
48
+ _check_byte(cipher_byte, "cipher_byte")
49
+ _check_byte(key_byte, "key_byte")
50
+ s = selector(key_byte)
51
+
52
+ if s == 0:
53
+ return (cipher_byte - key_byte) % 256
54
+ if s == 1:
55
+ return (cipher_byte + key_byte) % 256
56
+ if s == 2:
57
+ return cipher_byte ^ key_byte
58
+ return rotate_right(cipher_byte, key_byte % 8)
@@ -0,0 +1,23 @@
1
+ import pytest
2
+
3
+ from image_encryption import decrypt_bytes, encrypt, encrypt_bytes, generate_key
4
+ from image_encryption.exceptions import InvalidKeyError
5
+
6
+
7
+ @pytest.mark.parametrize("length", [0, 1, 2, 7, 256, 4097])
8
+ def test_roundtrip(length):
9
+ data = bytes((i * 17 + 31) % 256 for i in range(length))
10
+ key = generate_key(length)
11
+ assert decrypt_bytes(encrypt_bytes(data, key), key) == data
12
+
13
+
14
+ def test_encrypt_generates_key():
15
+ data = b"hello research encryption"
16
+ ciphertext, key = encrypt(data)
17
+ assert len(key) == len(data)
18
+ assert decrypt_bytes(ciphertext, key) == data
19
+
20
+
21
+ def test_invalid_key_length():
22
+ with pytest.raises(InvalidKeyError):
23
+ encrypt_bytes(b"abcdef", b"123")
@@ -0,0 +1,25 @@
1
+ import numpy as np
2
+ from PIL import Image
3
+
4
+ from image_encryption import decrypt_image, encrypt_image, load_key, save_key
5
+
6
+
7
+ def test_rgb_image_roundtrip(tmp_path):
8
+ data = np.zeros((32, 32, 3), dtype=np.uint8)
9
+ data[0, 0] = [12, 34, 56]
10
+ data[10, 20] = [255, 128, 7]
11
+
12
+ source = tmp_path / "input.png"
13
+ encrypted = tmp_path / "image.ienc"
14
+ decrypted = tmp_path / "decrypted.png"
15
+ key_file = tmp_path / "image.key"
16
+
17
+ Image.fromarray(data, mode="RGB").save(source)
18
+
19
+ key = encrypt_image(source, encrypted)
20
+ save_key(key, key_file)
21
+
22
+ decrypt_image(encrypted, load_key(key_file), decrypted)
23
+
24
+ restored = np.asarray(Image.open(decrypted))
25
+ assert np.array_equal(restored, data)
@@ -0,0 +1,44 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ nib = pytest.importorskip("nibabel")
5
+
6
+ from image_encryption import decrypt_nifti, encrypt_nifti
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "data",
11
+ [
12
+ np.array([-32768, -1000, 0, 1, 7899, 32767], dtype=np.int16).reshape(2, 3, 1),
13
+ np.array([-3.5, 0.0, 1.25, 7899.125], dtype=np.float32).reshape(2, 2, 1),
14
+ ],
15
+ )
16
+ def test_nifti_roundtrip(tmp_path, data):
17
+ source = tmp_path / "input.nii.gz"
18
+ encrypted = tmp_path / "volume.ienc"
19
+ decrypted = tmp_path / "restored.nii.gz"
20
+
21
+ affine = np.array([
22
+ [1.0, 0.0, 0.0, 10.0],
23
+ [0.0, 2.0, 0.0, 20.0],
24
+ [0.0, 0.0, 3.0, 30.0],
25
+ [0.0, 0.0, 0.0, 1.0],
26
+ ])
27
+
28
+ image = nib.Nifti1Image(data, affine)
29
+ image.header["descrip"] = b"dynamic encryption test"
30
+ nib.save(image, source)
31
+
32
+ key = encrypt_nifti(source, encrypted)
33
+ decrypt_nifti(encrypted, key, decrypted)
34
+
35
+ original = nib.load(source)
36
+ restored = nib.load(decrypted)
37
+
38
+ original_raw = np.asarray(original.dataobj.get_unscaled())
39
+ restored_raw = np.asarray(restored.dataobj.get_unscaled())
40
+
41
+ assert np.array_equal(original_raw, restored_raw)
42
+ assert original_raw.dtype.str == restored_raw.dtype.str
43
+ assert np.allclose(original.affine, restored.affine)
44
+ assert bytes(original.header["descrip"]) == bytes(restored.header["descrip"])
@@ -0,0 +1,17 @@
1
+ from image_encryption.operations import decrypt_byte, encrypt_byte, rotate_left, rotate_right
2
+
3
+
4
+ def test_every_byte_and_key_roundtrip():
5
+ for plain in range(256):
6
+ for key in range(256):
7
+ cipher = encrypt_byte(plain, key)
8
+ assert 0 <= cipher <= 255
9
+ assert decrypt_byte(cipher, key) == plain
10
+
11
+
12
+ def test_rotation():
13
+ value = 0b10000001
14
+ assert rotate_left(value, 1) == 0b00000011
15
+ assert rotate_right(0b00000011, 1) == value
16
+ assert rotate_left(value, 8) == value
17
+ assert rotate_right(value, 8) == value