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.
- dynamic_image_encryption-0.1.0/LICENSE +21 -0
- dynamic_image_encryption-0.1.0/PKG-INFO +153 -0
- dynamic_image_encryption-0.1.0/README.md +121 -0
- dynamic_image_encryption-0.1.0/pyproject.toml +37 -0
- dynamic_image_encryption-0.1.0/setup.cfg +4 -0
- dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/PKG-INFO +153 -0
- dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/SOURCES.txt +20 -0
- dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/dependency_links.txt +1 -0
- dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/requires.txt +18 -0
- dynamic_image_encryption-0.1.0/src/dynamic_image_encryption.egg-info/top_level.txt +1 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/__init__.py +15 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/container.py +62 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/core.py +36 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/exceptions.py +14 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/image.py +104 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/key.py +35 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/nifti.py +210 -0
- dynamic_image_encryption-0.1.0/src/image_encryption/operations.py +58 -0
- dynamic_image_encryption-0.1.0/tests/test_core.py +23 -0
- dynamic_image_encryption-0.1.0/tests/test_images.py +25 -0
- dynamic_image_encryption-0.1.0/tests/test_nifti.py +44 -0
- 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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
image_encryption
|
|
@@ -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
|