pysigillum 0.0.1__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.
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include *.py
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysigillum
3
+ Version: 0.0.1
4
+ Summary: python bindings for libseal
5
+ Home-page: https://rad.ctrlc.hu/nodes/rad.ctrlc.hu/rad:z3wp7SxqLnD2XbwYZB8zFxTEvt4Jq
6
+ Author: Stefan Marsiske
7
+ Author-email: seal@ctrlc.hu
8
+ License: LGPLv3
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Classifier: Topic :: Security
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: pysodium
15
+ Requires-Dist: SecureString
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: license
23
+ Dynamic: requires-dist
24
+ Dynamic: summary
25
+
26
+ # pyseal
27
+
28
+ pyseal offers Python bindings for the libseal library, allowing integration of Segmented Encryption and Authentication Layer into Python applications. It provides access to the features of the [libseal](https://github.com/stef/liboprf) library.
29
+
30
+ ## Installation
31
+
32
+ ### Prerequisites
33
+
34
+ - [libseal](https://rad.ctrlc.hu/nodes/rad.ctrlc.hu/rad:z3wp7SxqLnD2XbwYZB8zFxTEvt4Jq): The core library
35
+ - [libsodium](https://github.com/jedisct1/libsodium): Required dependency for libseal
36
+
37
+ ### Installing from PyPI
38
+
39
+ ```bash
40
+ pip install pyseal
41
+ ```
@@ -0,0 +1,16 @@
1
+ # pyseal
2
+
3
+ pyseal offers Python bindings for the libseal library, allowing integration of Segmented Encryption and Authentication Layer into Python applications. It provides access to the features of the [libseal](https://github.com/stef/liboprf) library.
4
+
5
+ ## Installation
6
+
7
+ ### Prerequisites
8
+
9
+ - [libseal](https://rad.ctrlc.hu/nodes/rad.ctrlc.hu/rad:z3wp7SxqLnD2XbwYZB8zFxTEvt4Jq): The core library
10
+ - [libsodium](https://github.com/jedisct1/libsodium): Required dependency for libseal
11
+
12
+ ### Installing from PyPI
13
+
14
+ ```bash
15
+ pip install pyseal
16
+ ```
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Wrapper for libseal library
4
+
5
+ SPDX-FileCopyrightText: 2026, Marsiske Stefan
6
+ SPDX-License-Identifier: LGPL-3.0-or-later
7
+
8
+ Copyright (c) 2026, Marsiske Stefan.
9
+ All rights reserved.
10
+
11
+ This file is part of libseal.
12
+
13
+ libseal is free software: you can redistribute it and/or
14
+ modify it under the terms of the GNU Lesser General Public License
15
+ as published by the Free Software Foundation, either version 3 of
16
+ the License, or (at your option) any later version.
17
+
18
+ libseal is distributed in the hope that it will be
19
+ useful, but WITHOUT ANY WARRANTY; without even the implied
20
+ warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
21
+ See the GNU Lesser General Public License for more details.
22
+
23
+ You should have received a copy of the GNU Lesser General Public License
24
+ along with libseal. If not, see <http://www.gnu.org/licenses/>.
25
+
26
+ """
27
+
28
+ import ctypes
29
+ import ctypes.util
30
+ import os, platform, sys
31
+ from typing import List, Tuple
32
+ from itertools import zip_longest
33
+
34
+ libseal = ctypes.cdll.LoadLibrary(ctypes.util.find_library('seal') or ctypes.util.find_library('libseal'))
35
+
36
+ if libseal is None or not libseal._name:
37
+ # dev fallback
38
+ _base = os.path.dirname(os.path.abspath(__file__))
39
+ _dir = os.path.abspath(os.path.join(_base, "..", ".."))
40
+ for _name in ["libseal.so", "libseal.dylib", "libseal.dll"]:
41
+ _path = os.path.join(_dir, _name)
42
+ if os.path.exists(_path):
43
+ try:
44
+ libseal = ctypes.CDLL(_path)
45
+ if libseal._name:
46
+ break
47
+ except OSError:
48
+ pass
49
+
50
+ if libseal is None or not libseal._name:
51
+ raise ValueError('Unable to find libseal')
52
+
53
+ import pysodium # noqa: E402 – loaded after libseal so ctypes isn't hijacked
54
+
55
+ def split_by_n(iterable, n):
56
+ return list(zip_longest(*[iter(iterable)]*n, fillvalue=''))
57
+
58
+ def __check(code):
59
+ if code != 0:
60
+ raise ValueError(f"error: {code}")
61
+
62
+ Nk = 32
63
+ COMMITMENT_LEN = 32
64
+ SEAL_STATE_BYTES = Nk * 2 + 32
65
+ Nn = pysodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES # 12
66
+ Nt = pysodium.crypto_aead_chacha20poly1305_ietf_ABYTES # 16
67
+
68
+ class buffer_t(ctypes.Structure):
69
+ _fields_ = [("len", ctypes.c_size_t), ("buf", ctypes.c_char_p)]
70
+
71
+ class t_g_header_t(ctypes.Structure):
72
+ _pack_ = 1
73
+ _fields_ = [("salt", ctypes.c_uint8 * 32),
74
+ ("commitment", ctypes.c_uint8 * COMMITMENT_LEN),
75
+ ("snapshot", ctypes.c_uint8 * 32),
76
+ ("wrapped_acc", ctypes.c_uint8 * 32),
77
+ ("n_seg", ctypes.c_uint64)]
78
+
79
+ def _buffer(b: bytes) -> buffer_t:
80
+ return buffer_t(ctypes.c_size_t(len(b)), b if b else None)
81
+
82
+ def _check_state(state):
83
+ if not isinstance(state, bytes) or len(state) != SEAL_STATE_BYTES:
84
+ raise ValueError(f"state must be {SEAL_STATE_BYTES} bytes of type bytes")
85
+
86
+ def _check_pos(i, is_final, plen, what):
87
+ if not isinstance(i, int) or not 0 <= i < (1 << 64):
88
+ raise ValueError("i must be a non-negative 64 bit integer")
89
+ if not isinstance(is_final, bool):
90
+ raise ValueError("is_final must be 0 or 1")
91
+ SEGMENT_MAX = 16384
92
+ if plen > SEGMENT_MAX or (not is_final and plen != SEGMENT_MAX):
93
+ raise ValueError(f"{what} of non-final segment must be {SEGMENT_MAX} bytes, got {plen}")
94
+
95
+
96
+ # StartEnc(K, N, G): Deterministic. Takes a secret key K, nonce N,
97
+ # and global associated data G. Outputs a ciphertext header T_g and
98
+ # an initial per-message encryption state S.
99
+ #
100
+ # int seal_startenc(const uint8_t cek[Nk], const uint8_t salt[32],
101
+ # const buffer_t * const G,
102
+ # t_g_header_t *ct_header,
103
+ # uint8_t state[SEAL_STATE_BYTES]);
104
+ def startenc(cek: bytes, salt: bytes, G: bytes = b"") -> (t_g_header_t, bytes):
105
+ if not isinstance(cek, bytes) or len(cek) != Nk:
106
+ raise ValueError(f"cek must be {Nk} bytes of type bytes")
107
+ if not isinstance(salt, bytes) or len(salt) != 32:
108
+ raise ValueError("salt must be 32 bytes of type bytes")
109
+ if not isinstance(G, bytes):
110
+ raise ValueError("G is not of type bytes")
111
+ G_buf = _buffer(G)
112
+ header = t_g_header_t()
113
+ state = ctypes.create_string_buffer(SEAL_STATE_BYTES)
114
+ __check(libseal.seal_startenc(cek, salt, ctypes.byref(G_buf),
115
+ ctypes.byref(header), state))
116
+ return header, state.raw
117
+
118
+
119
+ # StartDec(K, N, T_g, G): Deterministic. Takes key K, nonce N, global
120
+ # associated data G, and ciphertext header T_g. Outputs a per-
121
+ # message decryption state S or an error.
122
+ #
123
+ # int seal_startdec(const uint8_t cek[Nk], const uint8_t salt[32],
124
+ # const buffer_t * const G,
125
+ # const t_g_header_t *ct_header,
126
+ # uint8_t state[SEAL_STATE_BYTES]);
127
+ def startdec(cek: bytes, salt: bytes, ct_header: t_g_header_t, G: bytes = b"") -> bytes:
128
+ if not isinstance(cek, bytes) or len(cek) != Nk:
129
+ raise ValueError(f"cek must be {Nk} bytes of type bytes")
130
+ if not isinstance(salt, bytes) or len(salt) != 32:
131
+ raise ValueError("salt must be 32 bytes of type bytes")
132
+ if not isinstance(G, bytes):
133
+ raise ValueError("G is not of type bytes")
134
+ if not isinstance(ct_header, t_g_header_t):
135
+ raise ValueError("ct_header must be of type t_g_header_t")
136
+ G_buf = _buffer(G)
137
+ state = ctypes.create_string_buffer(SEAL_STATE_BYTES)
138
+ __check(libseal.seal_startdec(cek, salt, ctypes.byref(G_buf),
139
+ ctypes.byref(ct_header), state))
140
+ return state.raw
141
+
142
+
143
+ # int seal_encrypt_segment(const uint8_t state[SEAL_STATE_BYTES], const uint64_t i,
144
+ # const uint8_t is_final, const buffer_t * const A_i,
145
+ # const buffer_t * const P_i, t_g_header_t *ct_header,
146
+ # ct_segment_t *stored_i);
147
+ def encrypt_segment(state: bytes, i: int, is_final: bool,
148
+ A_i: bytes, P_i: bytes, ct_header: t_g_header_t) -> (t_g_header_t, (bytes, bytes, bytes)):
149
+ _check_state(state)
150
+ if not isinstance(A_i, bytes):
151
+ raise ValueError("A_i is not of type bytes")
152
+ if len(A_i) > 0xfffe:
153
+ raise ValueError(f"A_i exceeds MAX_INFO ({0xfffe}) at {len(A_i)}")
154
+ if not isinstance(P_i, bytes):
155
+ raise ValueError("P_i is not of type bytes")
156
+ if not isinstance(ct_header, t_g_header_t):
157
+ raise ValueError("ct_header must be of type t_g_header_t")
158
+ _check_pos(i, is_final, len(P_i), "plaintext segment")
159
+ stored_i = ctypes.create_string_buffer(Nn + Nt + len(P_i))
160
+ __check(libseal.seal_encrypt_segment(state, ctypes.c_uint64(i), ctypes.c_uint8(1 if is_final else 0),
161
+ ctypes.byref(_buffer(A_i)), ctypes.byref(_buffer(P_i)),
162
+ ctypes.byref(ct_header), stored_i))
163
+ nonce = stored_i.raw[:Nn]
164
+ tag = stored_i.raw[Nn:Nn+Nt]
165
+ ct = stored_i.raw[Nn+Nt:]
166
+ return ct_header, (nonce, tag, ct)
167
+
168
+
169
+ # int seal_decrypt_segment(const uint8_t state[SEAL_STATE_BYTES], const uint64_t i,
170
+ # const uint8_t is_final, const buffer_t * const A_i,
171
+ # const size_t ct_i_len, const ct_segment_t *stored_i,
172
+ # uint8_t P_i[ct_i_len]);
173
+ def decrypt_segment(state: bytes, i: int, is_final: bool,
174
+ A_i: bytes, stored_i: bytes) -> bytes:
175
+ _check_state(state)
176
+ if not isinstance(A_i, bytes):
177
+ raise ValueError("A_i is not of type bytes")
178
+ if len(A_i) > 0xfffe:
179
+ raise ValueError(f"A_i exceeds MAX_INFO ({0xfffe}) at {len(A_i)}")
180
+ if not isinstance(stored_i, bytes) or len(stored_i) < Nn + Nt:
181
+ raise ValueError(f"stored_i must be at least {Nn+Nt} bytes of type bytes")
182
+ ct_i_len = len(stored_i) - Nn - Nt
183
+ _check_pos(i, is_final, ct_i_len, "ciphertext segment")
184
+ P_i = ctypes.create_string_buffer(ct_i_len)
185
+ __check(libseal.seal_decrypt_segment(state, ctypes.c_uint64(i), ctypes.c_uint8(1 if is_final else 0),
186
+ ctypes.byref(_buffer(A_i)), ctypes.c_size_t(ct_i_len),
187
+ stored_i, P_i))
188
+ return P_i.raw if ct_i_len else b""
189
+
190
+
191
+ # int seal_snap_verify(const uint8_t state[SEAL_STATE_BYTES], const uint64_t n_seg,
192
+ # const uint8_t *tags[], const uint8_t snapshot0[32]);
193
+ def snap_verify(state: bytes, tags: list, snapshot: bytes) -> bool:
194
+ _check_state(state)
195
+ if not isinstance(tags, (list, tuple)):
196
+ raise ValueError("tags must be a list of bytes")
197
+ for t in tags:
198
+ if not isinstance(t, bytes) or len(t) != Nt:
199
+ raise ValueError(f"each tag must be {Nt} bytes of type bytes")
200
+ if not isinstance(snapshot, bytes) or len(snapshot) != 32:
201
+ raise ValueError("snapshot must be 32 bytes of type bytes")
202
+ tags_arr = (ctypes.c_char_p * (len(tags)))(*tags)
203
+ return libseal.seal_snap_verify(state, ctypes.c_uint64(len(tags)),
204
+ tags_arr, snapshot) == 0
205
+
206
+
207
+ # int seal_rewrite_segment(const uint8_t state[SEAL_STATE_BYTES], const uint64_t i,
208
+ # const uint8_t is_final, const buffer_t * const A_i,
209
+ # const buffer_t * const P_i, const uint8_t old_tag[Nt],
210
+ # t_g_header_t *ct_header, ct_segment_t *stored_i);
211
+ def rewrite_segment(state: bytes, i: int, is_final: bool, A_i: bytes, P_i: bytes,
212
+ old_tag: bytes, ct_header: t_g_header_t) -> (t_g_header_t, (bytes, bytes, bytes)):
213
+ _check_state(state)
214
+ if not isinstance(A_i, bytes):
215
+ raise ValueError("A_i is not of type bytes")
216
+ if len(A_i) > 0xfffe:
217
+ raise ValueError(f"A_i exceeds MAX_INFO ({0xfffe}) at {len(A_i)}")
218
+ if not isinstance(P_i, bytes):
219
+ raise ValueError("P_i is not of type bytes")
220
+ if not isinstance(old_tag, bytes) or len(old_tag) != Nt:
221
+ raise ValueError(f"old_tag must be {Nt} bytes of type bytes")
222
+ if not isinstance(ct_header, t_g_header_t):
223
+ raise ValueError("ct_header must be of type t_g_header_t")
224
+ if i >= ct_header.n_seg:
225
+ raise ValueError(f"segment index {i} is out of range (n_seg={ct_header.n_seg})")
226
+ _check_pos(i, is_final, len(P_i), "plaintext segment")
227
+ stored_i = ctypes.create_string_buffer(Nn + Nt + len(P_i))
228
+ __check(libseal.seal_rewrite_segment(state, ctypes.c_uint64(i), ctypes.c_uint8(1 if is_final else 0),
229
+ ctypes.byref(_buffer(A_i)), ctypes.byref(_buffer(P_i)),
230
+ old_tag, ctypes.byref(ct_header), stored_i))
231
+ nonce = stored_i.raw[:Nn]
232
+ tag = stored_i.raw[Nn:Nn+Nt]
233
+ ct = stored_i.raw[Nn+Nt:]
234
+ return ct_header, (nonce, tag, ct)
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysigillum
3
+ Version: 0.0.1
4
+ Summary: python bindings for libseal
5
+ Home-page: https://rad.ctrlc.hu/nodes/rad.ctrlc.hu/rad:z3wp7SxqLnD2XbwYZB8zFxTEvt4Jq
6
+ Author: Stefan Marsiske
7
+ Author-email: seal@ctrlc.hu
8
+ License: LGPLv3
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Classifier: Topic :: Security
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: pysodium
15
+ Requires-Dist: SecureString
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: license
23
+ Dynamic: requires-dist
24
+ Dynamic: summary
25
+
26
+ # pyseal
27
+
28
+ pyseal offers Python bindings for the libseal library, allowing integration of Segmented Encryption and Authentication Layer into Python applications. It provides access to the features of the [libseal](https://github.com/stef/liboprf) library.
29
+
30
+ ## Installation
31
+
32
+ ### Prerequisites
33
+
34
+ - [libseal](https://rad.ctrlc.hu/nodes/rad.ctrlc.hu/rad:z3wp7SxqLnD2XbwYZB8zFxTEvt4Jq): The core library
35
+ - [libsodium](https://github.com/jedisct1/libsodium): Required dependency for libseal
36
+
37
+ ### Installing from PyPI
38
+
39
+ ```bash
40
+ pip install pyseal
41
+ ```
@@ -0,0 +1,10 @@
1
+ MANIFEST.in
2
+ README.md
3
+ setup.py
4
+ pyseal/__init__.py
5
+ pysigillum.egg-info/PKG-INFO
6
+ pysigillum.egg-info/SOURCES.txt
7
+ pysigillum.egg-info/dependency_links.txt
8
+ pysigillum.egg-info/requires.txt
9
+ pysigillum.egg-info/top_level.txt
10
+ tests/test_pyseal.py
@@ -0,0 +1,2 @@
1
+ pysodium
2
+ SecureString
@@ -0,0 +1 @@
1
+ pyseal
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python
2
+
3
+ # SPDX-FileCopyrightText: 2026, Marsiske Stefan
4
+ # SPDX-License-Identifier: LGPL-3.0-or-later
5
+
6
+ import os
7
+ from setuptools import setup, find_packages
8
+
9
+ # Utility function to read the README file.
10
+ # Used for the long_description. It's nice, because now 1) we have a top level
11
+ # README file and 2) it's easier to type in the README file than to put a raw
12
+ # string in below ...
13
+ def read(fname):
14
+ return open(os.path.join(os.path.dirname(__file__), fname)).read()
15
+
16
+ setup(name = 'pysigillum',
17
+ version = '0.0.1',
18
+ description = 'python bindings for libseal',
19
+ license = "LGPLv3",
20
+ author = 'Stefan Marsiske',
21
+ author_email = 'seal@ctrlc.hu',
22
+ url = 'https://rad.ctrlc.hu/nodes/rad.ctrlc.hu/rad:z3wp7SxqLnD2XbwYZB8zFxTEvt4Jq',
23
+ long_description=read('README.md'),
24
+ long_description_content_type="text/markdown",
25
+ packages=find_packages(),
26
+ install_requires = ("pysodium", "SecureString"),
27
+ classifiers = ["Development Status :: 4 - Beta",
28
+ "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
29
+ "Topic :: Security :: Cryptography",
30
+ "Topic :: Security",
31
+ ],
32
+ )
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # SPDX-FileCopyrightText: 2026, Marsiske Stefan
4
+ # SPDX-License-Identifier: LGPL-3.0-or-later
5
+
6
+ # End-to-end tests for pyseal wrapper against libseal.
7
+
8
+ import pyseal
9
+ import pysodium
10
+ import unittest
11
+
12
+ CEK = bytes([0xAA] * 32)
13
+ SALT = bytes([0x04] * 32)
14
+ PLAINTEXT_0 = b"Hello, SEAL!"
15
+
16
+ EXPECTED_COMMITMENT = bytes.fromhex(
17
+ "f4fe69fb8cbdd13e5350efbe556eec72f8a15bf6b82d1cfa8d5766537798be6e")
18
+
19
+ SEAL_STATE_BYTES = pyseal.SEAL_STATE_BYTES
20
+ Nk = pyseal.Nk
21
+
22
+
23
+ class TestStartenc(unittest.TestCase):
24
+ """Deterministic properties of seal_startenc from testvectors.h."""
25
+
26
+ def test_returns_header_and_state(self):
27
+ header, state = pyseal.startenc(CEK, SALT)
28
+ self.assertIsInstance(header, pyseal.t_g_header_t)
29
+ self.assertEqual(len(state), SEAL_STATE_BYTES)
30
+
31
+ def test_commitment_matches_testvector(self):
32
+ header, _ = pyseal.startenc(CEK, SALT)
33
+ self.assertEqual(bytes(header.commitment), EXPECTED_COMMITMENT)
34
+
35
+ def test_state_contains_expected_payload_key(self):
36
+ _, state = pyseal.startenc(CEK, SALT)
37
+ expected_payload_key = bytes.fromhex(
38
+ "f3cc064929b3360130fe81ada4cf05048ed7597e38b3f352066af1cb253a0448")
39
+ self.assertEqual(state[:Nk], expected_payload_key)
40
+
41
+
42
+ class TestDecryptSegment(unittest.TestCase):
43
+ """Single-segment decrypt roundtrip."""
44
+
45
+ def test_roundtrip(self):
46
+ header, state = pyseal.startenc(CEK, SALT)
47
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, 0, True, b"", PLAINTEXT_0, header)
48
+ stored = nonce + tag + ct
49
+ # todo use startdec
50
+ result = pyseal.decrypt_segment(state, 0, True, b"", stored)
51
+ self.assertEqual(result, PLAINTEXT_0)
52
+
53
+ def test_input_validation(self):
54
+ _, state = pyseal.startenc(CEK, SALT)
55
+ with self.assertRaises(ValueError):
56
+ pyseal.decrypt_segment(b"\x00", 0, True, b"", b"")
57
+
58
+ def test_empty_plaintext(self):
59
+ header, state = pyseal.startenc(CEK, SALT)
60
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, 0, True, b"", b"", header)
61
+ self.assertEqual(nonce + tag + ct, nonce + tag)
62
+
63
+
64
+ class TestMultiSegment(unittest.TestCase):
65
+ """Multiple segments: encrypt each, decrypt each, verify snapshot."""
66
+
67
+ def test_three_segments_roundtrip(self):
68
+ header, state = pyseal.startenc(CEK, SALT)
69
+ segs = []
70
+ for idx in range(3):
71
+ pt = (b'A' * 12 + b'\x00')[:idx+5] if idx < 2 else b"final segment data here"
72
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
73
+ segs.append((idx, nonce + tag + ct, pt))
74
+
75
+ for idx, stored, expected_pt in segs:
76
+ result = pyseal.decrypt_segment(state, idx, True, b"", stored)
77
+ self.assertEqual(result, expected_pt)
78
+
79
+ def test_snap_verify_three_segments(self):
80
+ header, state = pyseal.startenc(CEK, SALT)
81
+ tags = []
82
+ for idx in range(3):
83
+ pt = (b'x' * 12 + b'\x00')[:idx+5]
84
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
85
+ tags.append(tag)
86
+
87
+ self.assertTrue(pyseal.snap_verify(state, tags, bytes(header.snapshot)))
88
+
89
+ def test_snap_rejects_wrong_tag(self):
90
+ header, state = pyseal.startenc(CEK, SALT)
91
+ pt = b"test data"
92
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, 0, True, b"", pt, header)
93
+ bad_tag = bytes([b ^ 0x01 for b in tag])
94
+ self.assertFalse(pyseal.snap_verify(state, [bad_tag], bytes(header.snapshot)))
95
+
96
+ def test_non_final_segments_must_be_segment_max(self):
97
+ from pyseal import _check_pos
98
+ with self.assertRaises(ValueError, msg="non-final too small"):
99
+ _check_pos(0, False, 5, "segment")
100
+ # Valid non-final at SEGMENT_MAX: should not raise
101
+ _check_pos(0, False, 16384, "segment")
102
+
103
+
104
+ class TestSegmentRewriting(unittest.TestCase):
105
+ """Rewrite segment: replace plaintext at a given index."""
106
+
107
+ def test_rewrite_roundtrip(self):
108
+ header, state = pyseal.startenc(CEK, SALT)
109
+ old_pt = b"old content here!"
110
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, 0, True, b"", old_pt, header)
111
+ stored_old = nonce + tag + ct
112
+
113
+ # Decrypt original
114
+ self.assertEqual(pyseal.decrypt_segment(state, 0, True, b"", stored_old), old_pt)
115
+
116
+ # Rewrite segment 0
117
+ new_pt = b"new content!"
118
+ header, (nonce2, tag2, ct2) = pyseal.rewrite_segment(state, 0, True, b"", new_pt, tag, header)
119
+ stored_new = nonce2 + tag2 + ct2
120
+
121
+ # Decrypt rewritten segment returns new plaintext
122
+ self.assertEqual(pyseal.decrypt_segment(state, 0, True, b"", stored_new), new_pt)
123
+
124
+ # Snapshot changed: old tag no longer verifies
125
+ self.assertFalse(pyseal.snap_verify(state, [tag], bytes(header.snapshot)))
126
+ self.assertTrue(pyseal.snap_verify(state, [tag2], bytes(header.snapshot)))
127
+
128
+ def test_input_validation(self):
129
+ header, state = pyseal.startenc(CEK, SALT)
130
+ header, (_, tag, _) = pyseal.encrypt_segment(state, 0, True, b"", b"x", header)
131
+ with self.assertRaises(ValueError):
132
+ pyseal.rewrite_segment(b"\x00", 0, True, b"", b"", tag, header)
133
+ # Segment index too large
134
+ with self.assertRaises(ValueError):
135
+ pyseal.rewrite_segment(state, 99, True, b"", b"", tag, header)
136
+
137
+
138
+ class TestTruncation(unittest.TestCase):
139
+ """Verify snap_detects incomplete ciphertext sets."""
140
+
141
+ def test_snap_rejects_incomplete_set(self):
142
+ """Verifying fewer tags than n_seg must fail."""
143
+ header, state = pyseal.startenc(CEK, SALT)
144
+ all_tags = []
145
+ for idx in range(5):
146
+ pt = b"segment %d data here padded to exact size!!" % idx
147
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
148
+ all_tags.append(tag)
149
+
150
+ # Snapshot was built over all 5 segments; verifying only 3 should fail
151
+ self.assertFalse(pyseal.snap_verify(state, all_tags[:3], bytes(header.snapshot)))
152
+
153
+ def test_snap_accepts_exact_set(self):
154
+ """Verifying exactly n_seg tags must pass."""
155
+ header, state = pyseal.startenc(CEK, SALT)
156
+ all_tags = []
157
+ for idx in range(5):
158
+ pt = b"segment %d data here padded to exact size!!" % idx
159
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
160
+ all_tags.append(tag)
161
+
162
+ self.assertTrue(pyseal.snap_verify(state, all_tags, bytes(header.snapshot)))
163
+
164
+ def test_rewrite_last_shorter(self):
165
+ """5 segments: last > SEGMENT_MAX/2 bytes, rewrite to < SEGMENT_MAX/2.
166
+ Still 5 segments after rewrite. Verify snap_verify and decrypt."""
167
+ header, state = pyseal.startenc(CEK, SALT)
168
+ all_tags = []
169
+ segs = {}
170
+
171
+ # Encrypt 5 segments; make last > SEGMENT_MAX/2 (e.g. ~9000 bytes)
172
+ for idx in range(5):
173
+ if idx == 4:
174
+ pt = b"X" * 9000 # > SEGMENT_MAX/2
175
+ else:
176
+ pt = b"S%d:" % idx + (b"DATA " * 10) # short data
177
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
178
+ all_tags.append(tag)
179
+ segs[idx] = (nonce + tag + ct, pt)
180
+
181
+ # Verify before rewrite
182
+ self.assertTrue(pyseal.snap_verify(state, all_tags, bytes(header.snapshot)))
183
+
184
+ # Decrypt original to confirm correctness
185
+ for idx, (stored, expected_pt) in segs.items():
186
+ result = pyseal.decrypt_segment(state, idx, True, b"", stored)
187
+ self.assertEqual(result, expected_pt)
188
+
189
+ # Rewrite last segment (index 4) from ~9000 down to < SEGMENT_MAX/2 (~7000)
190
+ new_last = b"Y" * 7000
191
+ header2, (new_nonce_4, new_tag_4, new_ct_4) = pyseal.rewrite_segment(
192
+ state, 4, True, b"", new_last, all_tags[4], header)
193
+ stored_4_new = new_nonce_4 + new_tag_4 + new_ct_4
194
+
195
+ # Verify snapshot passes with updated tags
196
+ updated_tags = list(all_tags)
197
+ updated_tags[4] = new_tag_4
198
+ self.assertTrue(pyseal.snap_verify(state, updated_tags, bytes(header2.snapshot)))
199
+
200
+ # Decrypt segments 0-3 unchanged
201
+ for idx in range(4):
202
+ stored, expected_pt = segs[idx]
203
+ result = pyseal.decrypt_segment(state, idx, True, b"", stored)
204
+ self.assertEqual(result, expected_pt, f"segment {idx} mismatch after rewrite")
205
+
206
+ # Segment 4 should now return the new smaller content
207
+ result_4 = pyseal.decrypt_segment(state, 4, True, b"", stored_4_new)
208
+ self.assertEqual(result_4, new_last)
209
+
210
+ def test_remove_last_segment(self):
211
+ """5 segments: rewrite last segment to empty. Effectively 4 data segments."""
212
+ header, state = pyseal.startenc(CEK, SALT)
213
+ all_tags = []
214
+ segs = {}
215
+
216
+ # Encrypt 5 segments with varied sizes
217
+ for idx in range(5):
218
+ pt = b"SEG%d:" % idx + (b"data " * (20 + idx))
219
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
220
+ all_tags.append(tag)
221
+ segs[idx] = (nonce + tag + ct, pt)
222
+
223
+ # Rewrite last segment (index 4) to empty; capture returned ciphertext
224
+ header, (new_nonce_4, new_tag_4, new_ct_4) = pyseal.rewrite_segment(state, 4, True, b"", b"", all_tags[4], header)
225
+ new_stored_4 = new_nonce_4 + new_tag_4 + new_ct_4
226
+
227
+ updated_tags = list(all_tags)
228
+ updated_tags[4] = new_tag_4
229
+
230
+ # snap_verify should pass with all 5 tags (including empty segment)
231
+ self.assertTrue(pyseal.snap_verify(state, updated_tags, bytes(header.snapshot)))
232
+
233
+ # Decrypt: segments 0-3 unchanged, segment 4 is now b""
234
+ for idx in range(4):
235
+ stored, expected_pt = segs[idx]
236
+ result = pyseal.decrypt_segment(state, idx, True, b"", stored)
237
+ self.assertEqual(result, expected_pt)
238
+
239
+ result_4 = pyseal.decrypt_segment(state, 4, True, b"", new_stored_4)
240
+ self.assertEqual(result_4, b"")
241
+
242
+ def test_remove_middle_segment(self):
243
+ """5 segments: rewrite 2nd segment (index 1) to empty."""
244
+ header, state = pyseal.startenc(CEK, SALT)
245
+ all_tags = []
246
+ segs = {}
247
+
248
+ # Encrypt 5 segments with varied sizes
249
+ for idx in range(5):
250
+ pt = b"SEG%d:" % idx + (b"data " * (20 + idx))
251
+ header, (nonce, tag, ct) = pyseal.encrypt_segment(state, idx, True, b"", pt, header)
252
+ all_tags.append(tag)
253
+ segs[idx] = (nonce + tag + ct, pt)
254
+
255
+ # Rewrite segment 1 to empty; capture returned ciphertext
256
+ header, (new_nonce_1, new_tag_1, new_ct_1) = pyseal.rewrite_segment(state, 1, True, b"", b"", all_tags[1], header)
257
+ new_stored_1 = new_nonce_1 + new_tag_1 + new_ct_1
258
+
259
+ updated_tags = list(all_tags)
260
+ updated_tags[1] = new_tag_1
261
+
262
+ # snap_verify should pass with all 5 tags (including empty segment at index 1)
263
+ self.assertTrue(pyseal.snap_verify(state, updated_tags, bytes(header.snapshot)))
264
+
265
+ # Decrypt: segment 1 is now b"", others unchanged
266
+ result_1 = pyseal.decrypt_segment(state, 1, True, b"", new_stored_1)
267
+ self.assertEqual(result_1, b"")
268
+
269
+ for idx in (0, 2, 3, 4):
270
+ stored, expected_pt = segs[idx]
271
+ result = pyseal.decrypt_segment(state, idx, True, b"", stored)
272
+ self.assertEqual(result, expected_pt)
273
+
274
+
275
+ class TestExtending(unittest.TestCase):
276
+ """Add more segments to an already encrypted stream."""
277
+
278
+ def test_extend_and_verify(self):
279
+ header, state = pyseal.startenc(CEK, SALT)
280
+
281
+ # Initial segment
282
+ header, (_, tag1, _) = pyseal.encrypt_segment(state, 0, True, b"", b"first", header)
283
+
284
+ # Verify after first segment
285
+ self.assertTrue(pyseal.snap_verify(state, [tag1], bytes(header.snapshot)))
286
+
287
+ # Add second segment
288
+ header, (_, tag2, _) = pyseal.encrypt_segment(state, 1, True, b"", b"second", header)
289
+ self.assertTrue(pyseal.snap_verify(state, [tag1, tag2], bytes(header.snapshot)))
290
+
291
+ # Finalize with third segment
292
+ header, (_, tag3, _) = pyseal.encrypt_segment(state, 2, True, b"", b"third", header)
293
+ self.assertTrue(pyseal.snap_verify(state, [tag1, tag2, tag3], bytes(header.snapshot)))
294
+
295
+
296
+ class TestStartdec(unittest.TestCase):
297
+ """startdec: reconstruct state from header."""
298
+
299
+ def test_reconstruct_state(self):
300
+ header, state_enc = pyseal.startenc(CEK, SALT)
301
+ state_dec = pyseal.startdec(CEK, SALT, header)
302
+ # State should be identical (deterministic)
303
+ self.assertEqual(state_enc, state_dec)
304
+
305
+ def test_startdec_with_header_commitment_mismatch(self):
306
+ header, _ = pyseal.startenc(CEK, SALT)
307
+ other_cek = b'\xbb' * 32
308
+ # Decryption with wrong cek should fail (commitment check fails)
309
+ with self.assertRaises(ValueError):
310
+ pyseal.startdec(other_cek, SALT, header)
311
+
312
+
313
+ class TestConstants(unittest.TestCase):
314
+ def test_module_constants(self):
315
+ self.assertEqual(pyseal.Nk, 32)
316
+ self.assertEqual(pyseal.COMMITMENT_LEN, 32)
317
+ self.assertEqual(pyseal.SEAL_STATE_BYTES, 96)
318
+ self.assertEqual(pyseal.Nn, pysodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES)
319
+ self.assertEqual(pyseal.Nt, pysodium.crypto_aead_chacha20poly1305_ietf_ABYTES)
320
+
321
+
322
+ if __name__ == "__main__":
323
+ unittest.main()