Pixseal 1.0.0__pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

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.
@@ -0,0 +1,399 @@
1
+ import hashlib
2
+ import json
3
+ from typing import TYPE_CHECKING, Sequence
4
+ import base64
5
+
6
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives import hashes
9
+ from cryptography.hazmat.primitives.asymmetric import padding
10
+
11
+ from .imageSigner import (
12
+ BinaryProvider,
13
+ addHiddenBit,
14
+ _build_payload_json,
15
+ make_channel_key,
16
+ _choose_channel,
17
+ )
18
+ from .keyInput import PublicKeyInput, resolve_public_key
19
+
20
+ # profiler check
21
+ try:
22
+ from line_profiler import profile
23
+ except ImportError:
24
+
25
+ def profile(func):
26
+ return func
27
+
28
+
29
+ # Dynamic typing
30
+ from .simpleImage import (
31
+ ImageInput as _RuntimeImageInput,
32
+ SimpleImage as _RuntimeSimpleImage,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from .simpleImage_py import ImageInput, SimpleImage
37
+ else:
38
+ ImageInput = _RuntimeImageInput
39
+ SimpleImage = _RuntimeSimpleImage
40
+
41
+ # JSON field names
42
+ PAYLOAD_FIELD: str = "payload"
43
+ PAYLOAD_SIG_FIELD: str = "payloadSig"
44
+ IMAGE_HASH_FIELD: str = "imageHash"
45
+ IMAGE_HASH_SIG_FIELD: str = "imageHashSig"
46
+
47
+ # Sentinel
48
+ START_SENTINEL: str = "START-VALIDATION"
49
+ END_SENTINEL: str = "END-VALIDATION"
50
+
51
+ # Report print option
52
+ TAIL_HEAD_LEN = 20
53
+ TAIL_SUFFIX_LEN = 10
54
+ FULL_TAIL_EXTRA_LEN = 20
55
+
56
+
57
+ def _is_json_like(value: str) -> bool:
58
+ return value.lstrip().startswith("{")
59
+
60
+
61
+ def _extract_payload_json(
62
+ deduplicated: list[str],
63
+ ) -> dict:
64
+ for value in deduplicated:
65
+ if not _is_json_like(value):
66
+ continue
67
+ if not value.rstrip().endswith("}"):
68
+ continue
69
+ try:
70
+ payload_obj = json.loads(value)
71
+ except json.JSONDecodeError:
72
+ continue
73
+ if not isinstance(payload_obj, dict):
74
+ continue
75
+ if not all(
76
+ key in payload_obj
77
+ for key in (
78
+ PAYLOAD_FIELD,
79
+ PAYLOAD_SIG_FIELD,
80
+ IMAGE_HASH_FIELD,
81
+ IMAGE_HASH_SIG_FIELD,
82
+ )
83
+ ):
84
+ continue
85
+ return payload_obj
86
+ return {}
87
+
88
+
89
+ def binaryToString(binaryCode):
90
+ string = []
91
+ for i in range(0, len(binaryCode), 8):
92
+ byte = binaryCode[i : i + 8]
93
+ decimal = int(byte, 2)
94
+ character = chr(decimal)
95
+ string.append(character)
96
+ return "".join(string)
97
+
98
+
99
+ @profile
100
+ def readHiddenBit(imageInput: ImageInput, channel_key: bytes | None = None):
101
+ img = (
102
+ imageInput
103
+ if isinstance(imageInput, SimpleImage)
104
+ else SimpleImage.open(imageInput)
105
+ )
106
+ width, height = img.size
107
+ pixels = img._pixels # direct buffer access for performance
108
+ total = width * height
109
+ bits = []
110
+ append_bit = bits.append
111
+
112
+ if channel_key is None:
113
+ for idx in range(total):
114
+ # Progress Check
115
+ # print("readHiddenBit Current : ", idx, "/", total)
116
+
117
+ base = idx * 3
118
+ r = pixels[base]
119
+ g = pixels[base + 1]
120
+ b = pixels[base + 2]
121
+
122
+ diffR = r - 127
123
+ if diffR < 0:
124
+ diffR = -diffR
125
+ diffG = g - 127
126
+ if diffG < 0:
127
+ diffG = -diffG
128
+ diffB = b - 127
129
+ if diffB < 0:
130
+ diffB = -diffB
131
+
132
+ maxDiff = diffR
133
+ if diffG > maxDiff:
134
+ maxDiff = diffG
135
+ if diffB > maxDiff:
136
+ maxDiff = diffB
137
+
138
+ bit = "1" if maxDiff % 2 == 0 else "0"
139
+ append_bit(bit)
140
+ else:
141
+ for idx in range(total):
142
+ base = idx * 3
143
+ channel = _choose_channel(idx, channel_key)
144
+ bit = pixels[base + channel] & 1
145
+ append_bit("1" if bit else "0")
146
+
147
+ return "".join(bits)
148
+
149
+
150
+ def deduplicate(arr: Sequence[str]) -> tuple[list[str], str]:
151
+ deduplicated = []
152
+ freq = {}
153
+ most_common = ""
154
+ most_count = 0
155
+
156
+ for i, value in enumerate(arr):
157
+ freq[value] = freq.get(value, 0) + 1
158
+ if freq[value] > most_count:
159
+ most_count = freq[value]
160
+ most_common = value
161
+
162
+ if i == 0 or value != arr[i - 1]:
163
+ deduplicated.append(value)
164
+
165
+ return deduplicated, most_common
166
+
167
+
168
+ # Check functions
169
+ def lengthCheck(arr: list[str]):
170
+ return len(arr) in (3, 4)
171
+
172
+
173
+ def tailCheck(arr: list[str]):
174
+ if len(arr) != 4:
175
+ return None # Not required
176
+
177
+ full_cipher = arr[1] # complete ciphertext
178
+ truncated_cipher = arr[2] # incomplete ciphertext
179
+
180
+ return full_cipher.startswith(truncated_cipher)
181
+
182
+
183
+ def verifySigniture(original: str, sig: str, publicKey: RSAPublicKey) -> bool:
184
+ try:
185
+ publicKey.verify(
186
+ data=original.encode("utf-8"),
187
+ signature=base64.b64decode(sig, validate=True),
188
+ padding=padding.PSS(
189
+ mgf=padding.MGF1(hashes.SHA256()),
190
+ salt_length=padding.PSS.MAX_LENGTH,
191
+ ),
192
+ algorithm=hashes.SHA256(),
193
+ )
194
+ except InvalidSignature:
195
+ return False
196
+ return True
197
+
198
+
199
+ # def buildValidationReport(
200
+ # decrypted, tailCheck: bool, skipPlain: bool = False, hashCheck=None
201
+ # ):
202
+ # # Length after deduplication/decryption
203
+ # arrayLength = len(decrypted)
204
+
205
+ # # 1. Check that the deduplicated sequence length is valid
206
+ # lengthCheck = arrayLength in (3, 4)
207
+
208
+ # # 2. Validate start/end markers
209
+ # startCheck = decrypted[0] == "START-VALIDATION" if decrypted else False
210
+ # endCheck = decrypted[-1] == "END-VALIDATION" if decrypted else False
211
+
212
+ # # 4. Determine whether payload was successfully decrypted
213
+ # decryptedPayload = decrypted[1] if len(decrypted) > 1 else ""
214
+ # isDecrypted = bool(decryptedPayload) and not decryptedPayload.endswith("==")
215
+
216
+ # checkList = [lengthCheck, startCheck, endCheck, isDecrypted]
217
+ # # 5. Parse tailCheck result
218
+ # if tailCheck is None:
219
+ # tailCheckResult = "Not Required"
220
+ # else:
221
+ # tailCheckResult = tailCheck
222
+ # checkList.append(tailCheckResult)
223
+
224
+ # if hashCheck is None:
225
+ # hashCheckResult = "Not Checked"
226
+ # else:
227
+ # hashCheckResult = hashCheck
228
+ # checkList.append(hashCheckResult)
229
+
230
+ # # Overall verdict requires every check to pass
231
+ # verdict = all(checkList)
232
+
233
+ # result = {
234
+ # "arrayLength": arrayLength,
235
+ # "lengthCheck": lengthCheck,
236
+ # "startCheck": startCheck,
237
+ # "endCheck": endCheck,
238
+ # "isDecrypted": isDecrypted,
239
+ # "tailCheckResult": tailCheckResult,
240
+ # "hashCheckResult": hashCheckResult,
241
+ # "verdict": verdict,
242
+ # }
243
+
244
+ # if skipPlain:
245
+ # result["decryptSkipMessage"] = (
246
+ # "Skip decrypt: payload was plain or corrupted text despite decrypt request."
247
+ # )
248
+
249
+ # return result
250
+
251
+
252
+ # main
253
+ def validateImage(imageInput: ImageInput, publicKey: PublicKeyInput):
254
+ """
255
+ Extract the embedded payload from an image and optionally decrypt it.
256
+
257
+ Args:
258
+ imageInput: File path, bytes, or file-like object accepted by SimpleImage.
259
+ publicKey: RSA public key or certificate (object, bytes, or path).
260
+
261
+ Returns:
262
+ Dict with the most common extracted string, decrypted sequence, and
263
+ a validation report describing the sentinel checks and verdict.
264
+ """
265
+
266
+ publicKey = resolve_public_key(publicKey)
267
+ channel_key = make_channel_key(publicKey)
268
+ resultBinary = readHiddenBit(imageInput, channel_key=channel_key)
269
+ resultString = binaryToString(resultBinary)
270
+ splitted = resultString.split("\n")
271
+
272
+ deduplicated, most_common = deduplicate(splitted)
273
+ if not deduplicated or most_common == "":
274
+ print("deduplicate : \n", deduplicated)
275
+ print("most_common : \n", most_common)
276
+ raise ValueError("deduplication failed!")
277
+
278
+ lengthCheckResult = lengthCheck(deduplicated)
279
+ tailCheckResult = tailCheck(deduplicated)
280
+
281
+ payload_obj = _extract_payload_json(deduplicated)
282
+ if not payload_obj:
283
+ raise ValueError("json extraction from payload failed!")
284
+
285
+ payload_text = payload_obj[PAYLOAD_FIELD]
286
+ payload_sig = payload_obj[PAYLOAD_SIG_FIELD]
287
+ image_hash = payload_obj[IMAGE_HASH_FIELD]
288
+ image_hash_sig = payload_obj[IMAGE_HASH_SIG_FIELD]
289
+ if (
290
+ not isinstance(payload_text, str)
291
+ or not isinstance(payload_sig, str)
292
+ or not isinstance(image_hash, str)
293
+ or not isinstance(image_hash_sig, str)
294
+ ):
295
+ raise TypeError("Essenstial value missing!")
296
+ imageHashVerifyResult = verifySigniture(
297
+ original=image_hash, sig=image_hash_sig, publicKey=publicKey
298
+ )
299
+ payloadVerifyResult = verifySigniture(
300
+ original=payload_text, sig=payload_sig, publicKey=publicKey
301
+ )
302
+ start_sig = deduplicated[0]
303
+ end_sig = deduplicated[-1]
304
+ startVerifyResult = verifySigniture(
305
+ original=START_SENTINEL, sig=start_sig, publicKey=publicKey
306
+ )
307
+ endVerifyResult = verifySigniture(
308
+ original=END_SENTINEL, sig=end_sig, publicKey=publicKey
309
+ )
310
+
311
+ image_hash_placeholder = "0" * len(image_hash)
312
+ image_hash_sig_placeholder = "0" * len(image_hash_sig)
313
+
314
+ payload_placeholder = _build_payload_json(
315
+ payload_text,
316
+ payload_sig,
317
+ image_hash_placeholder,
318
+ image_hash_sig_placeholder,
319
+ )
320
+
321
+ hiddenBinary = BinaryProvider(
322
+ payload=payload_placeholder + "\n",
323
+ startString=start_sig + "\n",
324
+ endString="\n" + end_sig,
325
+ )
326
+
327
+ placeholder_image = addHiddenBit(
328
+ imageInput,
329
+ hiddenBinary,
330
+ channel_key=channel_key,
331
+ )
332
+ computed_hash = hashlib.sha256(placeholder_image._pixels).hexdigest()
333
+ imageHashCompareCheckResult = image_hash == computed_hash
334
+
335
+ verdict = all(
336
+ [
337
+ lengthCheckResult,
338
+ tailCheckResult,
339
+ startVerifyResult,
340
+ endVerifyResult,
341
+ payloadVerifyResult,
342
+ imageHashVerifyResult,
343
+ imageHashCompareCheckResult,
344
+ ]
345
+ )
346
+
347
+ length_report = {
348
+ "length": len(deduplicated),
349
+ "result": lengthCheckResult,
350
+ }
351
+
352
+ if len(deduplicated) == 4:
353
+ full_value = deduplicated[1]
354
+ tail_value = deduplicated[2]
355
+ tail_min_len = TAIL_HEAD_LEN + TAIL_SUFFIX_LEN + 3
356
+ if len(tail_value) > tail_min_len:
357
+ tail_display = (
358
+ tail_value[:TAIL_HEAD_LEN] + "..." + tail_value[-TAIL_SUFFIX_LEN:]
359
+ )
360
+ else:
361
+ tail_display = tail_value
362
+ if len(full_value) > tail_min_len:
363
+ start = len(tail_value) - TAIL_SUFFIX_LEN
364
+ if start < 0:
365
+ start = 0
366
+ end = len(tail_value) + FULL_TAIL_EXTRA_LEN
367
+ if end > len(full_value):
368
+ end = len(full_value)
369
+ snippet = full_value[start:end]
370
+ if end < len(full_value):
371
+ snippet = snippet + "..."
372
+ full_display = full_value[:TAIL_HEAD_LEN] + "..." + snippet
373
+ else:
374
+ full_display = full_value
375
+ tail_report = {
376
+ "full": full_display,
377
+ "tail": tail_display,
378
+ "result": tailCheckResult,
379
+ }
380
+ else:
381
+ tail_report = {"result": "Not Required"}
382
+ hash_report = {
383
+ "extractedHash": image_hash,
384
+ "computedHash": computed_hash,
385
+ "result": imageHashCompareCheckResult,
386
+ }
387
+
388
+ report = {
389
+ "lengthCheck": length_report,
390
+ "tailCheck": tail_report,
391
+ "startVerify": startVerifyResult,
392
+ "endtVerify": endVerifyResult,
393
+ "payloadVerify": payloadVerifyResult,
394
+ "imageHashVerify": imageHashVerifyResult,
395
+ "imageHashCompareCheck": hash_report,
396
+ "verdict": verdict,
397
+ }
398
+
399
+ return report
Pixseal/keyInput.py ADDED
@@ -0,0 +1,91 @@
1
+ from pathlib import Path
2
+ from typing import TypeAlias
3
+
4
+ from cryptography import x509
5
+ from cryptography.hazmat.primitives import serialization
6
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
7
+
8
+ PublicKeyInput: TypeAlias = (
9
+ RSAPublicKey | x509.Certificate | bytes | bytearray | memoryview | str | Path
10
+ )
11
+
12
+ PrivateKeyInput: TypeAlias = RSAPrivateKey | bytes | bytearray | memoryview | str | Path
13
+
14
+
15
+ def _load_public_key_bytes(
16
+ public_key_input: bytes | bytearray | memoryview | str | Path,
17
+ ) -> bytes:
18
+ if isinstance(public_key_input, (bytes, bytearray, memoryview)):
19
+ return bytes(public_key_input)
20
+ public_key_path = Path(public_key_input)
21
+ if not public_key_path.is_file():
22
+ raise FileNotFoundError(
23
+ f"Public key/certificate file not found: {public_key_path}"
24
+ )
25
+ return public_key_path.read_bytes()
26
+
27
+
28
+ def _public_key_from_cert(cert: x509.Certificate) -> RSAPublicKey:
29
+ public_key = cert.public_key()
30
+ if not isinstance(public_key, RSAPublicKey):
31
+ raise TypeError("Certificate does not contain an RSA public key")
32
+
33
+ print("[Cert] Certificate loaded")
34
+ return public_key
35
+
36
+
37
+ def resolve_public_key(public_key_input: PublicKeyInput) -> RSAPublicKey:
38
+ if isinstance(public_key_input, RSAPublicKey):
39
+ return public_key_input
40
+ if isinstance(public_key_input, x509.Certificate):
41
+ return _public_key_from_cert(public_key_input)
42
+
43
+ key_data = _load_public_key_bytes(public_key_input)
44
+ if b"BEGIN CERTIFICATE" in key_data:
45
+ cert = x509.load_pem_x509_certificate(key_data)
46
+ return _public_key_from_cert(cert)
47
+ if b"BEGIN PUBLIC KEY" in key_data or b"BEGIN RSA PUBLIC KEY" in key_data:
48
+ public_key = serialization.load_pem_public_key(key_data)
49
+ if not isinstance(public_key, RSAPublicKey):
50
+ raise TypeError("Provided file is not an RSA public key")
51
+ return public_key
52
+
53
+ try:
54
+ cert = x509.load_der_x509_certificate(key_data)
55
+ except ValueError:
56
+ public_key = serialization.load_der_public_key(key_data)
57
+ if not isinstance(public_key, RSAPublicKey):
58
+ raise TypeError("Provided file is not an RSA public key")
59
+ return public_key
60
+
61
+ return _public_key_from_cert(cert)
62
+
63
+
64
+ def _load_private_key_bytes(
65
+ private_key_input: bytes | bytearray | memoryview | str | Path,
66
+ ) -> bytes:
67
+ if isinstance(private_key_input, (bytes, bytearray, memoryview)):
68
+ return bytes(private_key_input)
69
+ private_key_path = Path(private_key_input)
70
+ if not private_key_path.is_file():
71
+ raise FileNotFoundError(f"Private key file not found: {private_key_path}")
72
+ return private_key_path.read_bytes()
73
+
74
+
75
+ def resolve_private_key(private_key_input: PrivateKeyInput) -> RSAPrivateKey:
76
+ if isinstance(private_key_input, RSAPrivateKey):
77
+ return private_key_input
78
+
79
+ key_data = _load_private_key_bytes(private_key_input)
80
+ if b"BEGIN" in key_data:
81
+ if b"PRIVATE KEY" not in key_data:
82
+ raise ValueError("Provided file does not contain a private key")
83
+ if b"ENCRYPTED PRIVATE KEY" in key_data:
84
+ raise ValueError("Encrypted private keys require a password")
85
+ private_key = serialization.load_pem_private_key(key_data, password=None)
86
+ else:
87
+ private_key = serialization.load_der_private_key(key_data, password=None)
88
+
89
+ if not isinstance(private_key, RSAPrivateKey):
90
+ raise TypeError("Provided file is not an RSA private key")
91
+ return private_key
Pixseal/simpleImage.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ Loader that prefers the Cython implementation and falls back to pure Python.
3
+ Use environment variable PIXSEAL_SIMPLEIMAGE_BACKEND to force a backend:
4
+ - "cython": require compiled extension
5
+ - "python": force pure Python implementation
6
+ """
7
+
8
+ from os import getenv
9
+
10
+ _backend = getenv("PIXSEAL_SIMPLEIMAGE_BACKEND", "auto").lower()
11
+ _loaded_module = "Pixseal.simpleImage_py"
12
+
13
+ if _backend == "python":
14
+ from . import simpleImage_py as _impl # type: ignore
15
+ elif _backend == "cython":
16
+ try:
17
+ from . import simpleImage_ext as _impl # type: ignore
18
+ except ImportError as exc: # pragma: no cover
19
+ raise ImportError(
20
+ "Cython backend requested but Pixseal.simpleImage_ext is not available. "
21
+ "Build the extension or choose the python backend."
22
+ ) from exc
23
+ else:
24
+ try: # pragma: no cover
25
+ from . import simpleImage_ext as _impl # type: ignore
26
+ except ImportError: # pragma: no cover
27
+ from . import simpleImage_py as _impl # type: ignore
28
+
29
+ SimpleImage = _impl.SimpleImage # type: ignore
30
+ ImageInput = _impl.ImageInput # type: ignore
31
+ _loaded_module = _impl.__name__
32
+
33
+ print(f"[SimpleImage] Loaded from: {_loaded_module}")
34
+
35
+ __all__ = ["SimpleImage", "ImageInput"]