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.
- pysigillum-0.0.1/MANIFEST.in +2 -0
- pysigillum-0.0.1/PKG-INFO +41 -0
- pysigillum-0.0.1/README.md +16 -0
- pysigillum-0.0.1/pyseal/__init__.py +234 -0
- pysigillum-0.0.1/pysigillum.egg-info/PKG-INFO +41 -0
- pysigillum-0.0.1/pysigillum.egg-info/SOURCES.txt +10 -0
- pysigillum-0.0.1/pysigillum.egg-info/dependency_links.txt +1 -0
- pysigillum-0.0.1/pysigillum.egg-info/requires.txt +2 -0
- pysigillum-0.0.1/pysigillum.egg-info/top_level.txt +1 -0
- pysigillum-0.0.1/setup.cfg +4 -0
- pysigillum-0.0.1/setup.py +32 -0
- pysigillum-0.0.1/tests/test_pyseal.py +323 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyseal
|
|
@@ -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()
|