py-minisign 0.12.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Frank Denis <j at pureftpd dot org>
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,57 @@
1
+ Metadata-Version: 2.1
2
+ Name: py-minisign
3
+ Version: 0.12.0
4
+ Summary: Python minisign library
5
+ Author: Frank Denis, lucky
6
+ Project-URL: Homepage, https://github.com/x13a/py-minisign
7
+ Project-URL: Issues, https://github.com/x13a/py-minisign/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/x-rst
13
+ License-File: LICENSE
14
+ Requires-Dist: cryptography>=43.0.1
15
+
16
+ py-minisign
17
+ ===========
18
+
19
+ Missing python `minisign <https://github.com/jedisct1/minisign>`_ library.
20
+
21
+ Library
22
+ -------
23
+
24
+ .. code:: python
25
+
26
+ import os
27
+ import minisign
28
+
29
+ # verify
30
+
31
+ pk = minisign.PublicKey.from_base64(
32
+ 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3')
33
+ sig = minisign.Signature.from_bytes(
34
+ b'untrusted comment: signature from minisign secret key\n'
35
+ b'RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=\n'
36
+ b'trusted comment: timestamp:1555779966\tfile:test\n'
37
+ b'QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA=='
38
+ )
39
+ pk.verify(b'test', sig)
40
+
41
+ # sign
42
+
43
+ sk = minisign.SecretKey.from_file('/path/to/secret.key')
44
+ sk.decrypt('strong_password')
45
+ sig = sk.sign(b'very important data')
46
+
47
+ # generate key pair
48
+
49
+ key_pair = minisign.KeyPair.generate()
50
+ sk = key_pair.secret_key
51
+ pk = key_pair.public_key
52
+
53
+ # save key
54
+
55
+ sk.encrypt('strong_password')
56
+ with open(os.open('/path/to/secret.key', os.O_CREAT | os.O_WRONLY, 0o600), 'wb') as f:
57
+ f.write(bytes(sk))
@@ -0,0 +1,42 @@
1
+ py-minisign
2
+ ===========
3
+
4
+ Missing python `minisign <https://github.com/jedisct1/minisign>`_ library.
5
+
6
+ Library
7
+ -------
8
+
9
+ .. code:: python
10
+
11
+ import os
12
+ import minisign
13
+
14
+ # verify
15
+
16
+ pk = minisign.PublicKey.from_base64(
17
+ 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3')
18
+ sig = minisign.Signature.from_bytes(
19
+ b'untrusted comment: signature from minisign secret key\n'
20
+ b'RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=\n'
21
+ b'trusted comment: timestamp:1555779966\tfile:test\n'
22
+ b'QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA=='
23
+ )
24
+ pk.verify(b'test', sig)
25
+
26
+ # sign
27
+
28
+ sk = minisign.SecretKey.from_file('/path/to/secret.key')
29
+ sk.decrypt('strong_password')
30
+ sig = sk.sign(b'very important data')
31
+
32
+ # generate key pair
33
+
34
+ key_pair = minisign.KeyPair.generate()
35
+ sk = key_pair.secret_key
36
+ pk = key_pair.public_key
37
+
38
+ # save key
39
+
40
+ sk.encrypt('strong_password')
41
+ with open(os.open('/path/to/secret.key', os.O_CREAT | os.O_WRONLY, 0o600), 'wb') as f:
42
+ f.write(bytes(sk))
@@ -0,0 +1,17 @@
1
+ """
2
+ Minisign
3
+ """
4
+
5
+ __version__ = '0.12.0'
6
+
7
+ from .exceptions import (
8
+ Error,
9
+ ParseError,
10
+ VerifyError,
11
+ )
12
+ from .minisign import (
13
+ KeyPair,
14
+ PublicKey,
15
+ SecretKey,
16
+ Signature,
17
+ )
@@ -0,0 +1,10 @@
1
+ class Error(ValueError):
2
+ pass
3
+
4
+
5
+ class ParseError(Error):
6
+ pass
7
+
8
+
9
+ class VerifyError(Error):
10
+ pass
@@ -0,0 +1,47 @@
1
+ import hashlib
2
+ import io
3
+ from typing import (
4
+ BinaryIO,
5
+ Union,
6
+ )
7
+
8
+ from .exceptions import (
9
+ Error,
10
+ ParseError,
11
+ )
12
+
13
+
14
+ class Reader:
15
+ def __init__(self, data: bytes):
16
+ self._buf = data
17
+ self._pos = 0
18
+
19
+ def __len__(self) -> int:
20
+ return len(self._buf) - self._pos
21
+
22
+ def read(self, size: int) -> bytes:
23
+ pos = self._pos + size
24
+ data = self._buf[self._pos:pos]
25
+ if len(data) != size:
26
+ raise ParseError('read size mismatch')
27
+ self._pos = pos
28
+ return data
29
+
30
+
31
+ def read_data(data: Union[bytes, BinaryIO], prehash: bool) -> bytes:
32
+ if prehash:
33
+ if isinstance(data, io.BufferedIOBase):
34
+ hasher = hashlib.blake2b()
35
+ while chunk := data.read(1 << 13):
36
+ hasher.update(chunk)
37
+ data = hasher.digest()
38
+ else:
39
+ data = hashlib.blake2b(data).digest()
40
+ elif isinstance(data, io.BufferedIOBase):
41
+ data = data.read()
42
+ return data
43
+
44
+
45
+ def check_comment(s: str):
46
+ if '\n' in s:
47
+ raise Error('comment contains new line char')
@@ -0,0 +1,499 @@
1
+ """
2
+ https://jedisct1.github.io/minisign
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import enum
9
+ import hashlib
10
+ import os
11
+ import secrets
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import (
16
+ BinaryIO,
17
+ Optional,
18
+ Union,
19
+ )
20
+
21
+ from cryptography.exceptions import InvalidSignature
22
+ from cryptography.hazmat.primitives import serialization
23
+ from cryptography.hazmat.primitives.asymmetric import ed25519
24
+ from cryptography.hazmat.primitives.kdf import scrypt
25
+
26
+ from .exceptions import (
27
+ Error,
28
+ ParseError,
29
+ VerifyError,
30
+ )
31
+ from .helpers import (
32
+ Reader,
33
+ check_comment,
34
+ read_data,
35
+ )
36
+
37
+ ALG_LEN = 2
38
+ KDF_PARAM_LEN = 8
39
+ KEY_ID_LEN = 8
40
+ KEY_LEN = 32
41
+ SALT_LEN = 32
42
+ CHECKSUM_LEN = 32
43
+ SIG_LEN = 64
44
+
45
+ KEYNUM_PK_LEN = KEY_ID_LEN + KEY_LEN
46
+ KEYNUM_SK_LEN = KEY_ID_LEN + (KEY_LEN << 1) + CHECKSUM_LEN
47
+
48
+ OPSLIMIT = 1_048_576
49
+ MEMLIMIT = 33_554_432
50
+ MEMLIMIT_MAX = 1_073_741_824
51
+ N_LOG2_MAX = 20
52
+
53
+ SIG_EXT = 'minisig'
54
+ BYTE_ORDER = 'little'
55
+ DEFAULT_SK_PATH = '~/.minisign/minisign.key'
56
+
57
+ UNTRUSTED_COMMENT_PREFIX = 'untrusted comment: '
58
+ TRUSTED_COMMENT_PREFIX = 'trusted comment: '
59
+ TRUSTED_COMMENT_PREFIX_LEN = len(TRUSTED_COMMENT_PREFIX)
60
+
61
+
62
+ @enum.unique
63
+ class SignatureAlgorithm(bytes, enum.Enum):
64
+ PURE_ED_DSA = bytes([0x45, 0x64])
65
+ PREHASHED_ED_DSA = bytes([0x45, 0x44])
66
+
67
+
68
+ @enum.unique
69
+ class KDFAlgorithm(bytes, enum.Enum):
70
+ SCRYPT = bytes([0x53, 0x63])
71
+
72
+
73
+ @enum.unique
74
+ class CksumAlgorithm(bytes, enum.Enum):
75
+ BLAKE2b = bytes([0x42, 0x32])
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Signature:
80
+ _untrusted_comment: str
81
+ _signature_algorithm: SignatureAlgorithm
82
+ _key_id: bytes
83
+ _signature: bytes
84
+ _trusted_comment: str
85
+ _global_signature: bytes
86
+
87
+ @classmethod
88
+ def from_bytes(cls, data: bytes) -> Signature:
89
+ lines = data.splitlines()
90
+ if len(lines) < 4:
91
+ raise ParseError('incomplete encoded signature')
92
+ glob_sig = base64.standard_b64decode(lines[3])
93
+ if len(glob_sig) != SIG_LEN:
94
+ raise ParseError('invalid encoded signature')
95
+ buf = Reader(base64.standard_b64decode(lines[1]))
96
+ return cls(
97
+ _untrusted_comment=lines[0].decode(),
98
+ _signature_algorithm=SignatureAlgorithm(buf.read(ALG_LEN)),
99
+ _key_id=buf.read(KEY_ID_LEN),
100
+ _signature=buf.read(SIG_LEN),
101
+ _trusted_comment=lines[2].decode(),
102
+ _global_signature=glob_sig,
103
+ )
104
+
105
+ @classmethod
106
+ def from_file(cls, path: Union[str, os.PathLike]) -> Signature:
107
+ with open(path, 'rb') as f:
108
+ return cls.from_bytes(f.read())
109
+
110
+ @property
111
+ def untrusted_comment(self) -> str:
112
+ return self._untrusted_comment
113
+
114
+ def set_untrusted_comment(self, value: str):
115
+ check_comment(value)
116
+ self.__dict__['_untrusted_comment'] = value
117
+
118
+ @property
119
+ def trusted_comment(self) -> str:
120
+ return self._trusted_comment[TRUSTED_COMMENT_PREFIX_LEN:]
121
+
122
+ def __bytes__(self) -> bytes:
123
+ return b'\n'.join((
124
+ self._untrusted_comment.encode(),
125
+ base64.standard_b64encode(
126
+ self._signature_algorithm.value +
127
+ self._key_id +
128
+ self._signature
129
+ ),
130
+ self._trusted_comment.encode(),
131
+ base64.standard_b64encode(self._global_signature),
132
+ ))
133
+
134
+ def _is_prehashed(self) -> bool:
135
+ return self._signature_algorithm == SignatureAlgorithm.PREHASHED_ED_DSA
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class KeynumPK:
140
+ key_id: bytes
141
+ public_key: bytes
142
+
143
+ @classmethod
144
+ def from_bytes(cls, data: Union[bytes, Reader]) -> KeynumPK:
145
+ assert len(data) == KEYNUM_PK_LEN
146
+ if isinstance(data, bytes):
147
+ data = Reader(data)
148
+ return cls(
149
+ key_id=data.read(KEY_ID_LEN),
150
+ public_key=data.read(KEY_LEN),
151
+ )
152
+
153
+ def __bytes__(self) -> bytes:
154
+ return self.key_id + self.public_key
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class PublicKey:
159
+ _untrusted_comment: Optional[str]
160
+ _signature_algorithm: SignatureAlgorithm
161
+ _keynum_pk: KeynumPK
162
+
163
+ @classmethod
164
+ def from_base64(cls, s: Union[bytes, str]) -> PublicKey:
165
+ buf = Reader(base64.standard_b64decode(s))
166
+ return cls(
167
+ _untrusted_comment=None,
168
+ _signature_algorithm=SignatureAlgorithm(buf.read(ALG_LEN)),
169
+ _keynum_pk=KeynumPK.from_bytes(buf),
170
+ )
171
+
172
+ @classmethod
173
+ def from_bytes(cls, data: bytes) -> PublicKey:
174
+ lines = data.splitlines()
175
+ if len(lines) < 2:
176
+ raise ParseError('incomplete encoded public key')
177
+ pk = cls.from_base64(lines[1])
178
+ pk.set_untrusted_comment(lines[0].decode())
179
+ return pk
180
+
181
+ @classmethod
182
+ def from_file(cls, path: Union[str, os.PathLike]) -> PublicKey:
183
+ with open(path, 'rb') as f:
184
+ return cls.from_bytes(f.read())
185
+
186
+ @classmethod
187
+ def from_secret_key(cls, secret_key: SecretKey) -> PublicKey:
188
+ key_id = bytes(secret_key._keynum_sk.key_id)
189
+ return cls(
190
+ _untrusted_comment=f'{UNTRUSTED_COMMENT_PREFIX}'
191
+ f'minisign public key '
192
+ f'{key_id.hex().upper()}',
193
+ _signature_algorithm=secret_key._signature_algorithm,
194
+ _keynum_pk=KeynumPK(
195
+ key_id=key_id,
196
+ public_key=bytes(secret_key._keynum_sk.public_key),
197
+ ),
198
+ )
199
+
200
+ @property
201
+ def untrusted_comment(self) -> Optional[str]:
202
+ return self._untrusted_comment
203
+
204
+ def set_untrusted_comment(self, value: Optional[str]):
205
+ check_comment(value)
206
+ self.__dict__['_untrusted_comment'] = value
207
+
208
+ def verify(self, data: Union[bytes, BinaryIO], signature: Signature):
209
+ if self._keynum_pk.key_id != signature._key_id:
210
+ raise VerifyError('incompatible key identifiers')
211
+ if not signature._trusted_comment.startswith(TRUSTED_COMMENT_PREFIX):
212
+ raise VerifyError('unexpected format for the trusted comment')
213
+ pk = ed25519.Ed25519PublicKey.from_public_bytes(
214
+ self._keynum_pk.public_key)
215
+ try:
216
+ pk.verify(
217
+ signature._signature,
218
+ read_data(data, signature._is_prehashed()),
219
+ )
220
+ pk.verify(
221
+ signature._global_signature,
222
+ signature._signature + signature.trusted_comment.encode(),
223
+ )
224
+ except InvalidSignature as err:
225
+ raise VerifyError(err)
226
+
227
+ def verify_file(
228
+ self,
229
+ path: Union[str, os.PathLike],
230
+ signature: Optional[Signature] = None,
231
+ ):
232
+ if signature is None:
233
+ signature = Signature.from_file(f'{path}.{SIG_EXT}')
234
+ with open(path, 'rb') as f:
235
+ self.verify(f, signature)
236
+
237
+ def to_base64(self) -> bytes:
238
+ return base64.standard_b64encode(
239
+ self._signature_algorithm.value +
240
+ bytes(self._keynum_pk)
241
+ )
242
+
243
+ def __bytes__(self) -> bytes:
244
+ return b'\n'.join((
245
+ (
246
+ f'{UNTRUSTED_COMMENT_PREFIX}minisign public key '
247
+ f'{self._keynum_pk.key_id.hex().upper()}'
248
+ if self._untrusted_comment is None else
249
+ self._untrusted_comment
250
+ ).encode(),
251
+ self.to_base64(),
252
+ ))
253
+
254
+
255
+ @dataclass(frozen=True, repr=False)
256
+ class KeynumSK:
257
+ key_id: bytearray
258
+ secret_key: bytearray
259
+ public_key: bytearray
260
+ checksum: bytearray
261
+
262
+ @classmethod
263
+ def from_bytes(cls, data: Union[bytes, Reader]) -> KeynumSK:
264
+ assert len(data) == KEYNUM_SK_LEN
265
+ if isinstance(data, bytes):
266
+ data = Reader(data)
267
+ return cls(
268
+ key_id=bytearray(data.read(KEY_ID_LEN)),
269
+ secret_key=bytearray(data.read(KEY_LEN)),
270
+ public_key=bytearray(data.read(KEY_LEN)),
271
+ checksum=bytearray(data.read(CHECKSUM_LEN)),
272
+ )
273
+
274
+ def xor(self, key: bytes):
275
+ assert len(key) == KEYNUM_SK_LEN
276
+ buf = Reader(key)
277
+ for (l, size) in (
278
+ (self.key_id, KEY_ID_LEN),
279
+ (self.secret_key, KEY_LEN),
280
+ (self.public_key, KEY_LEN),
281
+ (self.checksum, CHECKSUM_LEN),
282
+ ):
283
+ for idx, (v1, v2) in enumerate(zip(l[:], buf.read(size))):
284
+ l[idx] = v1 ^ v2
285
+
286
+ def __bytes__(self) -> bytes:
287
+ return (
288
+ bytes(self.key_id) +
289
+ bytes(self.secret_key) +
290
+ bytes(self.public_key) +
291
+ bytes(self.checksum)
292
+ )
293
+
294
+
295
+ @dataclass(frozen=True, repr=False)
296
+ class SecretKey:
297
+ _untrusted_comment: str
298
+ _signature_algorithm: SignatureAlgorithm
299
+ _kdf_algorithm: KDFAlgorithm
300
+ _cksum_algorithm: CksumAlgorithm
301
+ _kdf_salt: bytes
302
+ _kdf_opslimit: int
303
+ _kdf_memlimit: int
304
+ _keynum_sk: KeynumSK
305
+
306
+ @classmethod
307
+ def from_bytes(cls, data: bytes) -> SecretKey:
308
+ lines = data.splitlines()
309
+ if len(lines) < 2:
310
+ raise ParseError('incomplete encoded secret key')
311
+ buf = Reader(base64.standard_b64decode(lines[1]))
312
+ return cls(
313
+ _untrusted_comment=lines[0].decode(),
314
+ _signature_algorithm=SignatureAlgorithm(buf.read(ALG_LEN)),
315
+ _kdf_algorithm=KDFAlgorithm(buf.read(ALG_LEN)),
316
+ _cksum_algorithm=CksumAlgorithm(buf.read(ALG_LEN)),
317
+ _kdf_salt=buf.read(SALT_LEN),
318
+ _kdf_opslimit=int.from_bytes(buf.read(KDF_PARAM_LEN), BYTE_ORDER),
319
+ _kdf_memlimit=int.from_bytes(buf.read(KDF_PARAM_LEN), BYTE_ORDER),
320
+ _keynum_sk=KeynumSK.from_bytes(buf),
321
+ )
322
+
323
+ @classmethod
324
+ def from_file(
325
+ cls,
326
+ path: Optional[Union[str, os.PathLike]] = None,
327
+ ) -> SecretKey:
328
+ if path is None:
329
+ path = Path(DEFAULT_SK_PATH).expanduser().resolve(strict=True)
330
+ with open(path, 'rb') as f:
331
+ return cls.from_bytes(f.read())
332
+
333
+ @property
334
+ def untrusted_comment(self) -> str:
335
+ return self._untrusted_comment
336
+
337
+ def set_untrusted_comment(self, value: str):
338
+ check_comment(value)
339
+ self.__dict__['_untrusted_comment'] = value
340
+
341
+ def get_public_key(self) -> PublicKey:
342
+ return PublicKey.from_secret_key(self)
343
+
344
+ def decrypt(self, password: str):
345
+ self._crypt(password)
346
+ if self._calc_checksum() != bytes(self._keynum_sk.checksum):
347
+ raise Error('wrong password for that key')
348
+
349
+ def encrypt(self, password: str):
350
+ self._crypt(password)
351
+
352
+ def _crypt(self, password: str):
353
+ if self._kdf_memlimit > MEMLIMIT_MAX:
354
+ raise Error('memlimit too high')
355
+ opslimit = max(32768, self._kdf_opslimit)
356
+ n_log2 = 1
357
+ r = 8
358
+ p = 0
359
+ if opslimit < self._kdf_memlimit // 32:
360
+ maxn = opslimit // (r * 4)
361
+ p = 1
362
+ else:
363
+ maxn = self._kdf_memlimit // (r * 128)
364
+ while n_log2 < 63:
365
+ if 1 << n_log2 > maxn // 2:
366
+ break
367
+ n_log2 += 1
368
+ if not p:
369
+ p = min(0x3fffffff, (opslimit // 4) // (1 << n_log2)) // r
370
+ if n_log2 > N_LOG2_MAX:
371
+ raise Error('n_log2 too high')
372
+ self._keynum_sk.xor(scrypt.Scrypt(
373
+ salt=self._kdf_salt,
374
+ length=KEYNUM_SK_LEN,
375
+ n=1 << n_log2,
376
+ r=r,
377
+ p=p,
378
+ ).derive(password.encode()))
379
+
380
+ def sign(
381
+ self,
382
+ data: Union[bytes, BinaryIO],
383
+ *,
384
+ prehash: bool = True,
385
+ untrusted_comment: Optional[str] = None,
386
+ trusted_comment: Optional[str] = None,
387
+ ) -> Signature:
388
+ untrusted_comment = (
389
+ f'{UNTRUSTED_COMMENT_PREFIX}minisign signature '
390
+ f'{self._keynum_sk.key_id.hex().upper()}'
391
+ if untrusted_comment is None else
392
+ untrusted_comment
393
+ )
394
+ check_comment(untrusted_comment)
395
+ trusted_comment = (
396
+ f'timestamp:{int(time.time())}'
397
+ if trusted_comment is None else
398
+ trusted_comment
399
+ )
400
+ check_comment(trusted_comment)
401
+ pk = ed25519.Ed25519PrivateKey.from_private_bytes(
402
+ self._keynum_sk.secret_key)
403
+ sig_sig = pk.sign(read_data(data, prehash))
404
+ return Signature(
405
+ _untrusted_comment=untrusted_comment,
406
+ _signature_algorithm=(
407
+ SignatureAlgorithm.PREHASHED_ED_DSA
408
+ if prehash else
409
+ SignatureAlgorithm.PURE_ED_DSA
410
+ ),
411
+ _key_id=self._keynum_sk.key_id,
412
+ _signature=sig_sig,
413
+ _trusted_comment=f'{TRUSTED_COMMENT_PREFIX}{trusted_comment}',
414
+ _global_signature=pk.sign(sig_sig + trusted_comment.encode()),
415
+ )
416
+
417
+ def sign_file(
418
+ self,
419
+ path: Union[str, os.PathLike],
420
+ *,
421
+ prehash: bool = False,
422
+ untrusted_comment: Optional[str] = None,
423
+ trusted_comment: Optional[str] = None,
424
+ drop_signature: bool = False,
425
+ ) -> Signature:
426
+ with open(path, 'rb') as f:
427
+ sig = self.sign(
428
+ f,
429
+ prehash=prehash,
430
+ untrusted_comment=untrusted_comment,
431
+ trusted_comment=trusted_comment,
432
+ )
433
+ if drop_signature:
434
+ with open(f'{path}.{SIG_EXT}', 'wb') as f1:
435
+ f1.write(bytes(sig))
436
+ f1.write(b'\n')
437
+ return sig
438
+
439
+ def _calc_checksum(self) -> bytes:
440
+ hasher = hashlib.blake2b(digest_size=CHECKSUM_LEN)
441
+ hasher.update(self._signature_algorithm.value)
442
+ hasher.update(self._keynum_sk.key_id)
443
+ hasher.update(self._keynum_sk.secret_key)
444
+ hasher.update(self._keynum_sk.public_key)
445
+ return hasher.digest()
446
+
447
+ def _update_checksum(self):
448
+ self._keynum_sk.checksum[0:] = self._calc_checksum()
449
+
450
+ def __bytes__(self) -> bytes:
451
+ return b'\n'.join((
452
+ self._untrusted_comment.encode(),
453
+ base64.standard_b64encode(
454
+ self._signature_algorithm.value +
455
+ self._kdf_algorithm.value +
456
+ self._cksum_algorithm.value +
457
+ self._kdf_salt +
458
+ self._kdf_opslimit.to_bytes(KDF_PARAM_LEN, BYTE_ORDER) +
459
+ self._kdf_memlimit.to_bytes(KDF_PARAM_LEN, BYTE_ORDER) +
460
+ bytes(self._keynum_sk)
461
+ ),
462
+ ))
463
+
464
+
465
+ @dataclass(frozen=True, repr=False)
466
+ class KeyPair:
467
+ secret_key: SecretKey
468
+ public_key: PublicKey
469
+
470
+ @classmethod
471
+ def generate(cls) -> KeyPair:
472
+ private_key = ed25519.Ed25519PrivateKey.generate()
473
+ key_id = secrets.token_bytes(KEY_ID_LEN)
474
+ sk = SecretKey(
475
+ _untrusted_comment=f'{UNTRUSTED_COMMENT_PREFIX}'
476
+ f'minisign secret key '
477
+ f'{key_id.hex().upper()}',
478
+ _signature_algorithm=SignatureAlgorithm.PURE_ED_DSA,
479
+ _kdf_algorithm=KDFAlgorithm.SCRYPT,
480
+ _cksum_algorithm=CksumAlgorithm.BLAKE2b,
481
+ _kdf_salt=secrets.token_bytes(SALT_LEN),
482
+ _kdf_opslimit=OPSLIMIT,
483
+ _kdf_memlimit=MEMLIMIT,
484
+ _keynum_sk=KeynumSK(
485
+ key_id=bytearray(key_id),
486
+ secret_key=bytearray(private_key.private_bytes(
487
+ encoding=serialization.Encoding.Raw,
488
+ format=serialization.PrivateFormat.Raw,
489
+ encryption_algorithm=serialization.NoEncryption(),
490
+ )),
491
+ public_key=bytearray(private_key.public_key().public_bytes(
492
+ encoding=serialization.Encoding.Raw,
493
+ format=serialization.PublicFormat.Raw,
494
+ )),
495
+ checksum=bytearray(CHECKSUM_LEN),
496
+ ),
497
+ )
498
+ sk._update_checksum()
499
+ return cls(secret_key=sk, public_key=PublicKey.from_secret_key(sk))
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.1
2
+ Name: py-minisign
3
+ Version: 0.12.0
4
+ Summary: Python minisign library
5
+ Author: Frank Denis, lucky
6
+ Project-URL: Homepage, https://github.com/x13a/py-minisign
7
+ Project-URL: Issues, https://github.com/x13a/py-minisign/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/x-rst
13
+ License-File: LICENSE
14
+ Requires-Dist: cryptography>=43.0.1
15
+
16
+ py-minisign
17
+ ===========
18
+
19
+ Missing python `minisign <https://github.com/jedisct1/minisign>`_ library.
20
+
21
+ Library
22
+ -------
23
+
24
+ .. code:: python
25
+
26
+ import os
27
+ import minisign
28
+
29
+ # verify
30
+
31
+ pk = minisign.PublicKey.from_base64(
32
+ 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3')
33
+ sig = minisign.Signature.from_bytes(
34
+ b'untrusted comment: signature from minisign secret key\n'
35
+ b'RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=\n'
36
+ b'trusted comment: timestamp:1555779966\tfile:test\n'
37
+ b'QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA=='
38
+ )
39
+ pk.verify(b'test', sig)
40
+
41
+ # sign
42
+
43
+ sk = minisign.SecretKey.from_file('/path/to/secret.key')
44
+ sk.decrypt('strong_password')
45
+ sig = sk.sign(b'very important data')
46
+
47
+ # generate key pair
48
+
49
+ key_pair = minisign.KeyPair.generate()
50
+ sk = key_pair.secret_key
51
+ pk = key_pair.public_key
52
+
53
+ # save key
54
+
55
+ sk.encrypt('strong_password')
56
+ with open(os.open('/path/to/secret.key', os.O_CREAT | os.O_WRONLY, 0o600), 'wb') as f:
57
+ f.write(bytes(sk))
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.rst
3
+ pyproject.toml
4
+ minisign/__init__.py
5
+ minisign/exceptions.py
6
+ minisign/helpers.py
7
+ minisign/minisign.py
8
+ py_minisign.egg-info/PKG-INFO
9
+ py_minisign.egg-info/SOURCES.txt
10
+ py_minisign.egg-info/dependency_links.txt
11
+ py_minisign.egg-info/requires.txt
12
+ py_minisign.egg-info/top_level.txt
13
+ tests/test_minisign.py
@@ -0,0 +1 @@
1
+ cryptography>=43.0.1
@@ -0,0 +1 @@
1
+ minisign
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "py-minisign"
3
+ dynamic = ["version"]
4
+ authors = [
5
+ { name = "Frank Denis" },
6
+ { name = "lucky" },
7
+ ]
8
+ description = "Python minisign library"
9
+ readme = "README.rst"
10
+ requires-python = ">= 3.8"
11
+ dependencies = [
12
+ "cryptography >= 43.0.1",
13
+ ]
14
+
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/x13a/py-minisign"
23
+ Issues = "https://github.com/x13a/py-minisign/issues"
24
+
25
+ [build-system]
26
+ requires = ["setuptools >= 61.0"]
27
+ build-backend = "setuptools.build_meta"
28
+
29
+ [tool.setuptools.dynamic]
30
+ version = { attr = "minisign.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,92 @@
1
+ import copy
2
+ import io
3
+ import secrets
4
+ import unittest
5
+
6
+ from minisign.minisign import (
7
+ KEYNUM_SK_LEN,
8
+ KeyPair,
9
+ PublicKey,
10
+ SecretKey,
11
+ Signature,
12
+ )
13
+
14
+
15
+ class MinisignTestCase(unittest.TestCase):
16
+ def test_verify_pure(self):
17
+ sig = Signature.from_bytes(
18
+ b'untrusted comment: signature from minisign secret key\n'
19
+ b'RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=\n'
20
+ b'trusted comment: timestamp:1555779966\tfile:test\n'
21
+ b'QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA=='
22
+ )
23
+ self.assertEqual(
24
+ sig.untrusted_comment,
25
+ 'untrusted comment: signature from minisign secret key',
26
+ )
27
+ self.assertEqual(
28
+ sig.trusted_comment,
29
+ 'timestamp:1555779966\tfile:test',
30
+ )
31
+ PublicKey.from_base64(
32
+ 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'
33
+ ).verify(b'test', sig)
34
+
35
+ def test_verify_prehashed(self):
36
+ sig = Signature.from_bytes(
37
+ b'untrusted comment: signature from minisign secret key\n'
38
+ b'RUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\n'
39
+ b'trusted comment: timestamp:1556193335\tfile:test\n'
40
+ b'y/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg=='
41
+ )
42
+ self.assertEqual(
43
+ sig.untrusted_comment,
44
+ 'untrusted comment: signature from minisign secret key',
45
+ )
46
+ self.assertEqual(
47
+ sig.trusted_comment,
48
+ 'timestamp:1556193335\tfile:test',
49
+ )
50
+ PublicKey.from_base64(
51
+ 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'
52
+ ).verify(b'test', sig)
53
+
54
+ def test_public_key_conv(self):
55
+ pk = KeyPair.generate().public_key
56
+ self.assertEqual(pk, PublicKey.from_bytes(bytes(pk)))
57
+
58
+ def test_secret_key_conv(self):
59
+ sk = KeyPair.generate().secret_key
60
+ self.assertEqual(sk, SecretKey.from_bytes(bytes(sk)))
61
+
62
+ def test_signature_conv(self):
63
+ sig = KeyPair.generate().secret_key.sign(b'data')
64
+ self.assertEqual(sig, Signature.from_bytes(bytes(sig)))
65
+
66
+ def test_keynum_sk_xor(self):
67
+ kn = KeyPair.generate().secret_key._keynum_sk
68
+ kn_origin = copy.deepcopy(kn)
69
+ key = secrets.token_bytes(KEYNUM_SK_LEN)
70
+ kn.xor(key)
71
+ self.assertNotEqual(kn_origin, kn)
72
+ kn.xor(key)
73
+ self.assertEqual(kn_origin, kn)
74
+
75
+ def test_secret_key_crypt(self):
76
+ sk = KeyPair.generate().secret_key
77
+ kn_origin = copy.deepcopy(sk._keynum_sk)
78
+ password = 'strong_password'
79
+ sk.encrypt(password)
80
+ self.assertNotEqual(kn_origin, sk._keynum_sk)
81
+ sk.decrypt(password)
82
+ self.assertEqual(kn_origin, sk._keynum_sk)
83
+
84
+ def test_sign_verify(self):
85
+ kp = KeyPair.generate()
86
+ data = b'very important data'
87
+ kp.public_key.verify(data, kp.secret_key.sign(data))
88
+ kp.public_key.verify(data, kp.secret_key.sign(data, prehash=True))
89
+ kp.public_key.verify(
90
+ io.BytesIO(data),
91
+ kp.secret_key.sign(io.BytesIO(data), prehash=True),
92
+ )