transcrypto 1.7.0__py3-none-any.whl → 1.8.0__py3-none-any.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.
- transcrypto/__init__.py +1 -1
- transcrypto/base.py +58 -339
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +368 -0
- transcrypto/cli/bidsecret.py +334 -0
- transcrypto/cli/clibase.py +303 -0
- transcrypto/cli/intmath.py +427 -0
- transcrypto/cli/publicalgos.py +877 -0
- transcrypto/profiler.py +10 -7
- transcrypto/transcrypto.py +40 -1986
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/METADATA +12 -10
- transcrypto-1.8.0.dist-info/RECORD +23 -0
- transcrypto-1.7.0.dist-info/RECORD +0 -17
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/entry_points.txt +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto CLI: AES and Hash commands."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pathlib
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from transcrypto import aes, base, transcrypto
|
|
14
|
+
from transcrypto.cli import clibase
|
|
15
|
+
|
|
16
|
+
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
|
|
17
|
+
|
|
18
|
+
# =================================== "HASH" COMMAND ===============================================
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
hash_app = typer.Typer(
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
help='Cryptographic Hashing (SHA-256 / SHA-512 / file).',
|
|
24
|
+
)
|
|
25
|
+
transcrypto.app.add_typer(hash_app, name='hash')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@hash_app.command(
|
|
29
|
+
'sha256',
|
|
30
|
+
help='SHA-256 of input `data`.',
|
|
31
|
+
epilog=(
|
|
32
|
+
'Example:\n\n\n\n'
|
|
33
|
+
'$ poetry run transcrypto -i bin hash sha256 xyz\n\n'
|
|
34
|
+
'3608bca1e44ea6c4d268eb6db02260269892c0b42b86bbf1e77a6fa16c3c9282\n\n'
|
|
35
|
+
'$ poetry run transcrypto -i b64 hash sha256 -- eHl6 # "xyz" in base-64\n\n'
|
|
36
|
+
'3608bca1e44ea6c4d268eb6db02260269892c0b42b86bbf1e77a6fa16c3c9282'
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
@clibase.CLIErrorGuard
|
|
40
|
+
def Hash256( # documentation is help/epilog/args # noqa: D103
|
|
41
|
+
*,
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
data: str = typer.Argument(..., help='Input data (raw text; or `--input-format <hex|b64|bin>`)'),
|
|
44
|
+
) -> None:
|
|
45
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
46
|
+
bt: bytes = transcrypto.BytesFromText(data, config.input_format)
|
|
47
|
+
config.console.print(transcrypto.BytesToText(base.Hash256(bt), config.output_format))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@hash_app.command(
|
|
51
|
+
'sha512',
|
|
52
|
+
help='SHA-512 of input `data`.',
|
|
53
|
+
epilog=(
|
|
54
|
+
'Example:\n\n\n\n'
|
|
55
|
+
'$ poetry run transcrypto -i bin hash sha512 xyz\n\n'
|
|
56
|
+
'4a3ed8147e37876adc8f76328e5abcc1b470e6acfc18efea0135f983604953a5'
|
|
57
|
+
'8e183c1a6086e91ba3e821d926f5fdeb37761c7ca0328a963f5e92870675b728\n\n'
|
|
58
|
+
'$ poetry run transcrypto -i b64 hash sha512 -- eHl6 # "xyz" in base-64\n\n'
|
|
59
|
+
'4a3ed8147e37876adc8f76328e5abcc1b470e6acfc18efea0135f983604953a5'
|
|
60
|
+
'8e183c1a6086e91ba3e821d926f5fdeb37761c7ca0328a963f5e92870675b728'
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
@clibase.CLIErrorGuard
|
|
64
|
+
def Hash512( # documentation is help/epilog/args # noqa: D103
|
|
65
|
+
*,
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
data: str = typer.Argument(..., help='Input data (raw text; or `--input-format <hex|b64|bin>`)'),
|
|
68
|
+
) -> None:
|
|
69
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
70
|
+
bt: bytes = transcrypto.BytesFromText(data, config.input_format)
|
|
71
|
+
config.console.print(transcrypto.BytesToText(base.Hash512(bt), config.output_format))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@hash_app.command(
|
|
75
|
+
'file',
|
|
76
|
+
help='SHA-256/512 hash of file contents, defaulting to SHA-256.',
|
|
77
|
+
epilog=(
|
|
78
|
+
'Example:\n\n\n\n'
|
|
79
|
+
'$ poetry run transcrypto hash file /etc/passwd --digest sha512\n\n'
|
|
80
|
+
'8966f5953e79f55dfe34d3dc5b160ac4a4a3f9cbd1c36695a54e28d77c7874df'
|
|
81
|
+
'f8595502f8a420608911b87d336d9e83c890f0e7ec11a76cb10b03e757f78aea'
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
@clibase.CLIErrorGuard
|
|
85
|
+
def HashFile( # documentation is help/epilog/args # noqa: D103
|
|
86
|
+
*,
|
|
87
|
+
ctx: typer.Context,
|
|
88
|
+
path: pathlib.Path = typer.Argument( # noqa: B008
|
|
89
|
+
...,
|
|
90
|
+
exists=True,
|
|
91
|
+
file_okay=True,
|
|
92
|
+
dir_okay=False,
|
|
93
|
+
readable=True,
|
|
94
|
+
resolve_path=True,
|
|
95
|
+
help='Path to existing file',
|
|
96
|
+
),
|
|
97
|
+
digest: str = typer.Option(
|
|
98
|
+
'sha256',
|
|
99
|
+
'-d',
|
|
100
|
+
'--digest',
|
|
101
|
+
click_type=click.Choice(['sha256', 'sha512'], case_sensitive=False),
|
|
102
|
+
help='Digest type, SHA-256 ("sha256") or SHA-512 ("sha512")',
|
|
103
|
+
),
|
|
104
|
+
) -> None:
|
|
105
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
106
|
+
config.console.print(
|
|
107
|
+
transcrypto.BytesToText(base.FileHash(str(path), digest=digest), config.output_format)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# =================================== "AES" COMMAND ================================================
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
aes_app = typer.Typer(
|
|
115
|
+
no_args_is_help=True,
|
|
116
|
+
help=(
|
|
117
|
+
'AES-256 operations (GCM/ECB) and key derivation. '
|
|
118
|
+
'No measures are taken here to prevent timing attacks.'
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
transcrypto.app.add_typer(aes_app, name='aes')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@aes_app.command(
|
|
125
|
+
'key',
|
|
126
|
+
help=(
|
|
127
|
+
'Derive key from a password (PBKDF2-HMAC-SHA256) with custom expensive '
|
|
128
|
+
'salt and iterations. Very good/safe for simple password-to-key but not for '
|
|
129
|
+
'passwords databases (because of constant salt).'
|
|
130
|
+
),
|
|
131
|
+
epilog=(
|
|
132
|
+
'Example:\n\n\n\n'
|
|
133
|
+
'$ poetry run transcrypto -o b64 aes key "correct horse battery staple"\n\n'
|
|
134
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es=\n\n' # cspell:disable-line
|
|
135
|
+
'$ poetry run transcrypto -p keyfile.out --protect hunter aes key '
|
|
136
|
+
'"correct horse battery staple"\n\n'
|
|
137
|
+
"AES key saved to 'keyfile.out'"
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
@clibase.CLIErrorGuard
|
|
141
|
+
def AESKeyFromPass( # documentation is help/epilog/args # noqa: D103
|
|
142
|
+
*,
|
|
143
|
+
ctx: typer.Context,
|
|
144
|
+
password: str = typer.Argument(..., help='Password (leading/trailing spaces ignored)'),
|
|
145
|
+
) -> None:
|
|
146
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
147
|
+
aes_key: aes.AESKey = aes.AESKey.FromStaticPassword(password)
|
|
148
|
+
if config.key_path is not None:
|
|
149
|
+
transcrypto.SaveObj(aes_key, str(config.key_path), config.protect)
|
|
150
|
+
config.console.print(f'AES key saved to {str(config.key_path)!r}')
|
|
151
|
+
else:
|
|
152
|
+
config.console.print(transcrypto.BytesToText(aes_key.key256, config.output_format))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@aes_app.command(
|
|
156
|
+
'encrypt',
|
|
157
|
+
help=(
|
|
158
|
+
'AES-256-GCM: safely encrypt `plaintext` with `-k`/`--key` or with '
|
|
159
|
+
'`-p`/`--key-path` keyfile. All inputs are raw, or you '
|
|
160
|
+
'can use `--input-format <hex|b64|bin>`. Attention: if you provide `-a`/`--aad` '
|
|
161
|
+
'(associated data, AAD), you will need to provide the same AAD when decrypting '
|
|
162
|
+
'and it is NOT included in the `ciphertext`/CT returned by this method!'
|
|
163
|
+
),
|
|
164
|
+
epilog=(
|
|
165
|
+
'Example:\n\n\n\n'
|
|
166
|
+
'$ poetry run transcrypto -i b64 -o b64 aes encrypt -k '
|
|
167
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es= -- AAAAAAB4eXo=\n\n' # cspell:disable-line
|
|
168
|
+
'F2_ZLrUw5Y8oDnbTP5t5xCUWX8WtVILLD0teyUi_37_4KHeV-YowVA==\n\n' # cspell:disable-line
|
|
169
|
+
'$ poetry run transcrypto -i b64 -o b64 aes encrypt -k '
|
|
170
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es= -a eHl6 -- AAAAAAB4eXo=\n\n' # cspell:disable-line
|
|
171
|
+
'xOlAHPUPpeyZHId-f3VQ_QKKMxjIW0_FBo9WOfIBrzjn0VkVV6xTRA==' # cspell:disable-line
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
@clibase.CLIErrorGuard
|
|
175
|
+
def AESEncrypt( # documentation is help/epilog/args # noqa: D103
|
|
176
|
+
*,
|
|
177
|
+
ctx: typer.Context,
|
|
178
|
+
plaintext: str = typer.Argument(..., help='Input data to encrypt (PT)'),
|
|
179
|
+
key: str | None = typer.Option(
|
|
180
|
+
None, '-k', '--key', help="Key if `-p`/`--key-path` wasn't used (32 bytes)"
|
|
181
|
+
),
|
|
182
|
+
aad: str = typer.Option(
|
|
183
|
+
'',
|
|
184
|
+
'-a',
|
|
185
|
+
'--aad',
|
|
186
|
+
help='Associated data (optional; has to be separately sent to receiver/stored)',
|
|
187
|
+
),
|
|
188
|
+
) -> None:
|
|
189
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
190
|
+
aes_key: aes.AESKey
|
|
191
|
+
if key:
|
|
192
|
+
key_bytes: bytes = transcrypto.BytesFromText(key, config.input_format)
|
|
193
|
+
if len(key_bytes) != 32: # noqa: PLR2004
|
|
194
|
+
raise base.InputError(f'invalid AES key size: {len(key_bytes)} bytes (expected 32)')
|
|
195
|
+
aes_key = aes.AESKey(key256=key_bytes)
|
|
196
|
+
elif config.key_path is not None:
|
|
197
|
+
aes_key = transcrypto.LoadObj(str(config.key_path), config.protect, aes.AESKey)
|
|
198
|
+
else:
|
|
199
|
+
raise base.InputError('provide -k/--key or -p/--key-path')
|
|
200
|
+
aad_bytes: bytes | None = transcrypto.BytesFromText(aad, config.input_format) if aad else None
|
|
201
|
+
pt: bytes = transcrypto.BytesFromText(plaintext, config.input_format)
|
|
202
|
+
ct: bytes = aes_key.Encrypt(pt, associated_data=aad_bytes)
|
|
203
|
+
config.console.print(transcrypto.BytesToText(ct, config.output_format))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@aes_app.command(
|
|
207
|
+
'decrypt',
|
|
208
|
+
help=(
|
|
209
|
+
'AES-256-GCM: safely decrypt `ciphertext` with `-k`/`--key` or with '
|
|
210
|
+
'`-p`/`--key-path` keyfile. All inputs are raw, or you '
|
|
211
|
+
'can use `--input-format <hex|b64|bin>`. Attention: if you provided `-a`/`--aad` '
|
|
212
|
+
'(associated data, AAD) during encryption, you will need to provide the same AAD now!'
|
|
213
|
+
),
|
|
214
|
+
epilog=(
|
|
215
|
+
'Example:\n\n\n\n'
|
|
216
|
+
'$ poetry run transcrypto -i b64 -o b64 aes decrypt -k '
|
|
217
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es= -- ' # cspell:disable-line
|
|
218
|
+
'F2_ZLrUw5Y8oDnbTP5t5xCUWX8WtVILLD0teyUi_37_4KHeV-YowVA==\n\n' # cspell:disable-line
|
|
219
|
+
'AAAAAAB4eXo=\n\n' # cspell:disable-line
|
|
220
|
+
'$ poetry run transcrypto -i b64 -o b64 aes decrypt -k '
|
|
221
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es= -a eHl6 -- ' # cspell:disable-line
|
|
222
|
+
'xOlAHPUPpeyZHId-f3VQ_QKKMxjIW0_FBo9WOfIBrzjn0VkVV6xTRA==\n\n' # cspell:disable-line
|
|
223
|
+
'AAAAAAB4eXo=' # cspell:disable-line
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
@clibase.CLIErrorGuard
|
|
227
|
+
def AESDecrypt( # documentation is help/epilog/args # noqa: D103
|
|
228
|
+
*,
|
|
229
|
+
ctx: typer.Context,
|
|
230
|
+
ciphertext: str = typer.Argument(..., help='Input data to decrypt (CT)'),
|
|
231
|
+
key: str | None = typer.Option(
|
|
232
|
+
None, '-k', '--key', help="Key if `-p`/`--key-path` wasn't used (32 bytes)"
|
|
233
|
+
),
|
|
234
|
+
aad: str = typer.Option(
|
|
235
|
+
'',
|
|
236
|
+
'-a',
|
|
237
|
+
'--aad',
|
|
238
|
+
help='Associated data (optional; has to be exactly the same as used during encryption)',
|
|
239
|
+
),
|
|
240
|
+
) -> None:
|
|
241
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
242
|
+
aes_key: aes.AESKey
|
|
243
|
+
if key:
|
|
244
|
+
key_bytes: bytes = transcrypto.BytesFromText(key, config.input_format)
|
|
245
|
+
if len(key_bytes) != 32: # noqa: PLR2004
|
|
246
|
+
raise base.InputError(f'invalid AES key size: {len(key_bytes)} bytes (expected 32)')
|
|
247
|
+
aes_key = aes.AESKey(key256=key_bytes)
|
|
248
|
+
elif config.key_path is not None:
|
|
249
|
+
aes_key = transcrypto.LoadObj(str(config.key_path), config.protect, aes.AESKey)
|
|
250
|
+
else:
|
|
251
|
+
raise base.InputError('provide -k/--key or -p/--key-path')
|
|
252
|
+
# associated data, if any
|
|
253
|
+
aad_bytes: bytes | None = transcrypto.BytesFromText(aad, config.input_format) if aad else None
|
|
254
|
+
ct: bytes = transcrypto.BytesFromText(ciphertext, config.input_format)
|
|
255
|
+
pt: bytes = aes_key.Decrypt(ct, associated_data=aad_bytes)
|
|
256
|
+
config.console.print(transcrypto.BytesToText(pt, config.output_format))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ================================ "AES ECB" SUB-COMMAND ===========================================
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
aes_ecb_app = typer.Typer(
|
|
263
|
+
no_args_is_help=True,
|
|
264
|
+
help=(
|
|
265
|
+
'AES-256-ECB: encrypt/decrypt 128 bit (16 bytes) hexadecimal blocks. UNSAFE, except '
|
|
266
|
+
'for specifically encrypting hash blocks which are very much expected to look random. '
|
|
267
|
+
'ECB mode will have the same output for the same input (no IV/nonce is used).'
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
aes_app.add_typer(aes_ecb_app, name='ecb')
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@aes_ecb_app.command(
|
|
274
|
+
'encrypt',
|
|
275
|
+
help=(
|
|
276
|
+
'AES-256-ECB: encrypt 16-bytes hex `plaintext` with `-k`/`--key` or with '
|
|
277
|
+
'`-p`/`--key-path` keyfile. UNSAFE, except for specifically encrypting hash blocks.'
|
|
278
|
+
),
|
|
279
|
+
epilog=(
|
|
280
|
+
'Example:\n\n\n\n'
|
|
281
|
+
'$ poetry run transcrypto -i b64 aes ecb -k '
|
|
282
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es= encrypt ' # cspell:disable-line
|
|
283
|
+
'00112233445566778899aabbccddeeff\n\n' # cspell:disable-line
|
|
284
|
+
'54ec742ca3da7b752e527b74e3a798d7'
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
@clibase.CLIErrorGuard
|
|
288
|
+
def AESECBEncrypt( # documentation is help/epilog/args # noqa: D103
|
|
289
|
+
*,
|
|
290
|
+
ctx: typer.Context,
|
|
291
|
+
plaintext: str = typer.Argument(..., help='Plaintext block as 32 hex chars (16-bytes)'),
|
|
292
|
+
key: str | None = typer.Option(
|
|
293
|
+
None,
|
|
294
|
+
'-k',
|
|
295
|
+
'--key',
|
|
296
|
+
help=(
|
|
297
|
+
"Key if `-p`/`--key-path` wasn't used (32 bytes; raw, or you "
|
|
298
|
+
'can use `--input-format <hex|b64|bin>`)'
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
) -> None:
|
|
302
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
303
|
+
plaintext = plaintext.strip()
|
|
304
|
+
if len(plaintext) != 32: # noqa: PLR2004
|
|
305
|
+
raise base.InputError('hexadecimal string must be exactly 32 hex chars')
|
|
306
|
+
if not _HEX_RE.match(plaintext):
|
|
307
|
+
raise base.InputError(f'invalid hexadecimal string: {plaintext!r}')
|
|
308
|
+
aes_key: aes.AESKey
|
|
309
|
+
if key:
|
|
310
|
+
key_bytes: bytes = transcrypto.BytesFromText(key, config.input_format)
|
|
311
|
+
if len(key_bytes) != 32: # noqa: PLR2004
|
|
312
|
+
raise base.InputError(f'invalid AES key size: {len(key_bytes)} bytes (expected 32)')
|
|
313
|
+
aes_key = aes.AESKey(key256=key_bytes)
|
|
314
|
+
elif config.key_path is not None:
|
|
315
|
+
aes_key = transcrypto.LoadObj(str(config.key_path), config.protect, aes.AESKey)
|
|
316
|
+
else:
|
|
317
|
+
raise base.InputError('provide -k/--key or -p/--key-path')
|
|
318
|
+
ecb: aes.AESKey.ECBEncoderClass = aes_key.ECBEncoder()
|
|
319
|
+
config.console.print(ecb.EncryptHex(plaintext))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@aes_ecb_app.command(
|
|
323
|
+
'decrypt',
|
|
324
|
+
help=(
|
|
325
|
+
'AES-256-ECB: decrypt 16-bytes hex `ciphertext` with `-k`/`--key` or with '
|
|
326
|
+
'`-p`/`--key-path` keyfile. UNSAFE, except for specifically encrypting hash blocks.'
|
|
327
|
+
),
|
|
328
|
+
epilog=(
|
|
329
|
+
'Example:\n\n\n\n'
|
|
330
|
+
'$ poetry run transcrypto -i b64 aes ecb -k '
|
|
331
|
+
'DbWJ_ZrknLEEIoq_NpoCQwHYfjskGokpueN2O_eY0es= decrypt ' # cspell:disable-line
|
|
332
|
+
'54ec742ca3da7b752e527b74e3a798d7\n\n' # cspell:disable-line
|
|
333
|
+
'00112233445566778899aabbccddeeff' # cspell:disable-line
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
@clibase.CLIErrorGuard
|
|
337
|
+
def AESECBDecrypt( # documentation is help/epilog/args # noqa: D103
|
|
338
|
+
*,
|
|
339
|
+
ctx: typer.Context,
|
|
340
|
+
ciphertext: str = typer.Argument(..., help='Ciphertext block as 32 hex chars (16-bytes)'),
|
|
341
|
+
key: str | None = typer.Option(
|
|
342
|
+
None,
|
|
343
|
+
'-k',
|
|
344
|
+
'--key',
|
|
345
|
+
help=(
|
|
346
|
+
"Key if `-p`/`--key-path` wasn't used (32 bytes; raw, or you "
|
|
347
|
+
'can use `--input-format <hex|b64|bin>`)'
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
) -> None:
|
|
351
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
352
|
+
ciphertext = ciphertext.strip()
|
|
353
|
+
if len(ciphertext) != 32: # noqa: PLR2004
|
|
354
|
+
raise base.InputError('hexadecimal string must be exactly 32 hex chars')
|
|
355
|
+
if not _HEX_RE.match(ciphertext):
|
|
356
|
+
raise base.InputError(f'invalid hexadecimal string: {ciphertext!r}')
|
|
357
|
+
aes_key: aes.AESKey
|
|
358
|
+
if key:
|
|
359
|
+
key_bytes: bytes = transcrypto.BytesFromText(key, config.input_format)
|
|
360
|
+
if len(key_bytes) != 32: # noqa: PLR2004
|
|
361
|
+
raise base.InputError(f'invalid AES key size: {len(key_bytes)} bytes (expected 32)')
|
|
362
|
+
aes_key = aes.AESKey(key256=key_bytes)
|
|
363
|
+
elif config.key_path is not None:
|
|
364
|
+
aes_key = transcrypto.LoadObj(str(config.key_path), config.protect, aes.AESKey)
|
|
365
|
+
else:
|
|
366
|
+
raise base.InputError('provide -k/--key or -p/--key-path')
|
|
367
|
+
ecb: aes.AESKey.ECBEncoderClass = aes_key.ECBEncoder()
|
|
368
|
+
config.console.print(ecb.DecryptHex(ciphertext))
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto CLI: Bid secret and SSS commands."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from transcrypto import base, sss, transcrypto
|
|
12
|
+
from transcrypto.cli import clibase
|
|
13
|
+
|
|
14
|
+
# ================================== "BID" COMMAND =================================================
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
bid_app = typer.Typer(
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
help=(
|
|
20
|
+
'Bidding on a `secret` so that you can cryptographically convince a neutral '
|
|
21
|
+
'party that the `secret` that was committed to previously was not changed. '
|
|
22
|
+
'All methods require file key(s) as `-p`/`--key-path` (see provided examples). '
|
|
23
|
+
'All non-int inputs are raw, or you can use `--input-format <hex|b64|bin>`. '
|
|
24
|
+
'No measures are taken here to prevent timing attacks.'
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
transcrypto.app.add_typer(bid_app, name='bid')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@bid_app.command(
|
|
31
|
+
'new',
|
|
32
|
+
help=('Generate the bid files for `secret`.'),
|
|
33
|
+
epilog=(
|
|
34
|
+
'Example:\n\n\n\n'
|
|
35
|
+
'$ poetry run transcrypto -i bin -p my-bid bid new "tomorrow it will rain"\n\n'
|
|
36
|
+
"Bid private/public commitments saved to 'my-bid.priv/.pub'"
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
@clibase.CLIErrorGuard
|
|
40
|
+
def BidNew( # documentation is help/epilog/args # noqa: D103
|
|
41
|
+
*,
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
secret: str = typer.Argument(..., help='Input data to bid to, the protected "secret"'),
|
|
44
|
+
) -> None:
|
|
45
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
46
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'bid')
|
|
47
|
+
secret_bytes: bytes = transcrypto.BytesFromText(secret, config.input_format)
|
|
48
|
+
bid_priv: base.PrivateBid512 = base.PrivateBid512.New(secret_bytes)
|
|
49
|
+
bid_pub: base.PublicBid512 = base.PublicBid512.Copy(bid_priv)
|
|
50
|
+
transcrypto.SaveObj(bid_priv, base_path + '.priv', config.protect)
|
|
51
|
+
transcrypto.SaveObj(bid_pub, base_path + '.pub', config.protect)
|
|
52
|
+
config.console.print(f'Bid private/public commitments saved to {base_path + ".priv/.pub"!r}')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@bid_app.command(
|
|
56
|
+
'verify',
|
|
57
|
+
help=('Verify the bid files for correctness and reveal the `secret`.'),
|
|
58
|
+
epilog=(
|
|
59
|
+
'Example:\n\n\n\n'
|
|
60
|
+
'$ poetry run transcrypto -o bin -p my-bid bid verify\n\n'
|
|
61
|
+
'Bid commitment: OK\n\n'
|
|
62
|
+
'Bid secret:\n\n'
|
|
63
|
+
'tomorrow it will rain'
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
@clibase.CLIErrorGuard
|
|
67
|
+
def BidVerify(*, ctx: typer.Context) -> None: # documentation is help/epilog/args # noqa: D103
|
|
68
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
69
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'bid')
|
|
70
|
+
bid_priv: base.PrivateBid512 = transcrypto.LoadObj(
|
|
71
|
+
base_path + '.priv', config.protect, base.PrivateBid512
|
|
72
|
+
)
|
|
73
|
+
bid_pub: base.PublicBid512 = transcrypto.LoadObj(
|
|
74
|
+
base_path + '.pub', config.protect, base.PublicBid512
|
|
75
|
+
)
|
|
76
|
+
bid_pub_expect: base.PublicBid512 = base.PublicBid512.Copy(bid_priv)
|
|
77
|
+
config.console.print(
|
|
78
|
+
'Bid commitment: '
|
|
79
|
+
+ (
|
|
80
|
+
'[green]OK[/]'
|
|
81
|
+
if (
|
|
82
|
+
bid_pub.VerifyBid(bid_priv.private_key, bid_priv.secret_bid) and bid_pub == bid_pub_expect
|
|
83
|
+
)
|
|
84
|
+
else '[red]INVALID[/]'
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
config.console.print('Bid secret:')
|
|
88
|
+
config.console.print(transcrypto.BytesToText(bid_priv.secret_bid, config.output_format))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ================================== "SSS" COMMAND =================================================
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
sss_app = typer.Typer(
|
|
95
|
+
no_args_is_help=True,
|
|
96
|
+
help=(
|
|
97
|
+
'SSS (Shamir Shared Secret) secret sharing crypto scheme. '
|
|
98
|
+
'All methods require file key(s) as `-p`/`--key-path` (see provided examples). '
|
|
99
|
+
'All non-int inputs are raw, or you can use `--input-format <hex|b64|bin>`. '
|
|
100
|
+
'No measures are taken here to prevent timing attacks.'
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
transcrypto.app.add_typer(sss_app, name='sss')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@sss_app.command(
|
|
107
|
+
'new',
|
|
108
|
+
help=(
|
|
109
|
+
'Generate the private keys with `bits` prime modulus size and so that at least a '
|
|
110
|
+
'`minimum` number of shares are needed to recover the secret. '
|
|
111
|
+
'This key will be used to generate the shares later (with the `shares` command).'
|
|
112
|
+
),
|
|
113
|
+
epilog=(
|
|
114
|
+
'Example:\n\n\n\n'
|
|
115
|
+
'$ poetry run transcrypto -p sss-key sss new 3 --bits 64 '
|
|
116
|
+
'# NEVER use such a small key: example only!\n\n'
|
|
117
|
+
"SSS private/public keys saved to 'sss-key.priv/.pub'"
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
@clibase.CLIErrorGuard
|
|
121
|
+
def SSSNew( # documentation is help/epilog/args # noqa: D103
|
|
122
|
+
*,
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
minimum: int = typer.Argument(
|
|
125
|
+
..., min=2, help='Minimum number of shares required to recover secret, ≥ 2'
|
|
126
|
+
),
|
|
127
|
+
bits: int = typer.Option(
|
|
128
|
+
1024,
|
|
129
|
+
'-b',
|
|
130
|
+
'--bits',
|
|
131
|
+
min=16,
|
|
132
|
+
help=(
|
|
133
|
+
'Prime modulus (`p`) size in bits, ≥16; the default (1024) is a safe size ***IFF*** you '
|
|
134
|
+
'are protecting symmetric keys; the number of bits should be comfortably larger '
|
|
135
|
+
'than the size of the secret you want to protect with this scheme'
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
) -> None:
|
|
139
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
140
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'sss')
|
|
141
|
+
sss_priv: sss.ShamirSharedSecretPrivate = sss.ShamirSharedSecretPrivate.New(minimum, bits)
|
|
142
|
+
sss_pub: sss.ShamirSharedSecretPublic = sss.ShamirSharedSecretPublic.Copy(sss_priv)
|
|
143
|
+
transcrypto.SaveObj(sss_priv, base_path + '.priv', config.protect)
|
|
144
|
+
transcrypto.SaveObj(sss_pub, base_path + '.pub', config.protect)
|
|
145
|
+
config.console.print(f'SSS private/public keys saved to {base_path + ".priv/.pub"!r}')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@sss_app.command(
|
|
149
|
+
'rawshares',
|
|
150
|
+
help=(
|
|
151
|
+
'Raw shares: Issue `count` private shares for an *integer* `secret` '
|
|
152
|
+
'(BEWARE: no modern message wrapping, padding or validation).'
|
|
153
|
+
),
|
|
154
|
+
epilog=(
|
|
155
|
+
'Example:\n\n\n\n'
|
|
156
|
+
'$ poetry run transcrypto -p sss-key sss rawshares 999 5\n\n'
|
|
157
|
+
"SSS 5 individual (private) shares saved to 'sss-key.share.1…5'\n\n"
|
|
158
|
+
'$ rm sss-key.share.2 sss-key.share.4 # this is to simulate only having shares 1,3,5'
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
@clibase.CLIErrorGuard
|
|
162
|
+
def SSSRawShares( # documentation is help/epilog/args # noqa: D103
|
|
163
|
+
*,
|
|
164
|
+
ctx: typer.Context,
|
|
165
|
+
secret: str = typer.Argument(..., help='Integer secret to be protected, 1≤`secret`<*modulus*'),
|
|
166
|
+
count: int = typer.Argument(
|
|
167
|
+
...,
|
|
168
|
+
min=1,
|
|
169
|
+
help=(
|
|
170
|
+
'How many shares to produce; must be ≥ `minimum` used in `new` command or else the '
|
|
171
|
+
'`secret` would become unrecoverable'
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
) -> None:
|
|
175
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
176
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'sss')
|
|
177
|
+
sss_priv: sss.ShamirSharedSecretPrivate = transcrypto.LoadObj(
|
|
178
|
+
base_path + '.priv', config.protect, sss.ShamirSharedSecretPrivate
|
|
179
|
+
)
|
|
180
|
+
if count < sss_priv.minimum:
|
|
181
|
+
raise base.InputError(
|
|
182
|
+
f'count ({count}) must be >= minimum ({sss_priv.minimum}) to allow secret recovery'
|
|
183
|
+
)
|
|
184
|
+
secret_i: int = transcrypto.ParseInt(secret, min_value=1)
|
|
185
|
+
for i, share in enumerate(sss_priv.RawShares(secret_i, max_shares=count)):
|
|
186
|
+
transcrypto.SaveObj(share, f'{base_path}.share.{i + 1}', config.protect)
|
|
187
|
+
config.console.print(
|
|
188
|
+
f'SSS {count} individual (private) shares saved to {base_path + ".share.1…" + str(count)!r}'
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@sss_app.command(
|
|
193
|
+
'rawrecover',
|
|
194
|
+
help=(
|
|
195
|
+
'Raw recover *integer* secret from shares; will use any available shares '
|
|
196
|
+
'that were found (BEWARE: no modern message wrapping, padding or validation).'
|
|
197
|
+
),
|
|
198
|
+
epilog=(
|
|
199
|
+
'Example:\n\n\n\n'
|
|
200
|
+
'$ poetry run transcrypto -p sss-key sss rawrecover\n\n'
|
|
201
|
+
"Loaded SSS share: 'sss-key.share.3'\n\n"
|
|
202
|
+
"Loaded SSS share: 'sss-key.share.5'\n\n"
|
|
203
|
+
"Loaded SSS share: 'sss-key.share.1' # using only 3 shares: number 2/4 are missing\n\n"
|
|
204
|
+
'Secret:\n\n'
|
|
205
|
+
'999'
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
@clibase.CLIErrorGuard
|
|
209
|
+
def SSSRawRecover(*, ctx: typer.Context) -> None: # documentation is help/epilog/args # noqa: D103
|
|
210
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
211
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'sss')
|
|
212
|
+
sss_pub: sss.ShamirSharedSecretPublic = transcrypto.LoadObj(
|
|
213
|
+
base_path + '.pub', config.protect, sss.ShamirSharedSecretPublic
|
|
214
|
+
)
|
|
215
|
+
subset: list[sss.ShamirSharePrivate] = []
|
|
216
|
+
for fname in glob.glob(base_path + '.share.*'): # noqa: PTH207
|
|
217
|
+
subset.append(transcrypto.LoadObj(fname, config.protect, sss.ShamirSharePrivate))
|
|
218
|
+
config.console.print(f'Loaded SSS share: {fname!r}')
|
|
219
|
+
config.console.print('Secret:')
|
|
220
|
+
config.console.print(sss_pub.RawRecoverSecret(subset))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@sss_app.command(
|
|
224
|
+
'rawverify',
|
|
225
|
+
help=(
|
|
226
|
+
'Raw verify shares against a secret (private params; '
|
|
227
|
+
'BEWARE: no modern message wrapping, padding or validation).'
|
|
228
|
+
),
|
|
229
|
+
epilog=(
|
|
230
|
+
'Example:\n\n\n\n'
|
|
231
|
+
'$ poetry run transcrypto -p sss-key sss rawverify 999\n\n'
|
|
232
|
+
"SSS share 'sss-key.share.3' verification: OK\n\n"
|
|
233
|
+
"SSS share 'sss-key.share.5' verification: OK\n\n"
|
|
234
|
+
"SSS share 'sss-key.share.1' verification: OK\n\n"
|
|
235
|
+
'$ poetry run transcrypto -p sss-key sss rawverify 998\n\n'
|
|
236
|
+
"SSS share 'sss-key.share.3' verification: INVALID\n\n"
|
|
237
|
+
"SSS share 'sss-key.share.5' verification: INVALID\n\n"
|
|
238
|
+
"SSS share 'sss-key.share.1' verification: INVALID"
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
@clibase.CLIErrorGuard
|
|
242
|
+
def SSSRawVerify( # documentation is help/epilog/args # noqa: D103
|
|
243
|
+
*,
|
|
244
|
+
ctx: typer.Context,
|
|
245
|
+
secret: str = typer.Argument(..., help='Integer secret used to generate the shares, ≥ 1'),
|
|
246
|
+
) -> None:
|
|
247
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
248
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'sss')
|
|
249
|
+
sss_priv: sss.ShamirSharedSecretPrivate = transcrypto.LoadObj(
|
|
250
|
+
base_path + '.priv', config.protect, sss.ShamirSharedSecretPrivate
|
|
251
|
+
)
|
|
252
|
+
secret_i: int = transcrypto.ParseInt(secret, min_value=1)
|
|
253
|
+
for fname in glob.glob(base_path + '.share.*'): # noqa: PTH207
|
|
254
|
+
share: sss.ShamirSharePrivate = transcrypto.LoadObj(
|
|
255
|
+
fname, config.protect, sss.ShamirSharePrivate
|
|
256
|
+
)
|
|
257
|
+
config.console.print(
|
|
258
|
+
f'SSS share {fname!r} verification: '
|
|
259
|
+
f'{"OK" if sss_priv.RawVerifyShare(secret_i, share) else "INVALID"}'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@sss_app.command(
|
|
264
|
+
'shares',
|
|
265
|
+
help='Shares: Issue `count` private shares for a `secret`.',
|
|
266
|
+
epilog=(
|
|
267
|
+
'Example:\n\n\n\n'
|
|
268
|
+
'$ poetry run transcrypto -i bin -p sss-key sss shares "abcde" 5\n\n'
|
|
269
|
+
"SSS 5 individual (private) shares saved to 'sss-key.share.1…5'\n\n"
|
|
270
|
+
'$ rm sss-key.share.2 sss-key.share.4 # this is to simulate only having shares 1,3,5'
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
@clibase.CLIErrorGuard
|
|
274
|
+
def SSSShares( # documentation is help/epilog/args # noqa: D103
|
|
275
|
+
*,
|
|
276
|
+
ctx: typer.Context,
|
|
277
|
+
secret: str = typer.Argument(..., help='Secret to be protected'),
|
|
278
|
+
count: int = typer.Argument(
|
|
279
|
+
...,
|
|
280
|
+
help=(
|
|
281
|
+
'How many shares to produce; must be ≥ `minimum` used in `new` command or else the '
|
|
282
|
+
'`secret` would become unrecoverable'
|
|
283
|
+
),
|
|
284
|
+
),
|
|
285
|
+
) -> None:
|
|
286
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
287
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'sss')
|
|
288
|
+
sss_priv: sss.ShamirSharedSecretPrivate = transcrypto.LoadObj(
|
|
289
|
+
base_path + '.priv', config.protect, sss.ShamirSharedSecretPrivate
|
|
290
|
+
)
|
|
291
|
+
if count < sss_priv.minimum:
|
|
292
|
+
raise base.InputError(
|
|
293
|
+
f'count ({count}) must be >= minimum ({sss_priv.minimum}) to allow secret recovery'
|
|
294
|
+
)
|
|
295
|
+
pt: bytes = transcrypto.BytesFromText(secret, config.input_format)
|
|
296
|
+
for i, data_share in enumerate(sss_priv.MakeDataShares(pt, count)):
|
|
297
|
+
transcrypto.SaveObj(data_share, f'{base_path}.share.{i + 1}', config.protect)
|
|
298
|
+
config.console.print(
|
|
299
|
+
f'SSS {count} individual (private) shares saved to {base_path + ".share.1…" + str(count)!r}'
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@sss_app.command(
|
|
304
|
+
'recover',
|
|
305
|
+
help='Recover secret from shares; will use any available shares that were found.',
|
|
306
|
+
epilog=(
|
|
307
|
+
'Example:\n\n\n\n'
|
|
308
|
+
'$ poetry run transcrypto -o bin -p sss-key sss recover\n\n'
|
|
309
|
+
"Loaded SSS share: 'sss-key.share.3'\n\n"
|
|
310
|
+
"Loaded SSS share: 'sss-key.share.5'\n\n"
|
|
311
|
+
"Loaded SSS share: 'sss-key.share.1' # using only 3 shares: number 2/4 are missing\n\n"
|
|
312
|
+
'Secret:\n\n'
|
|
313
|
+
'abcde'
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
@clibase.CLIErrorGuard
|
|
317
|
+
def SSSRecover(*, ctx: typer.Context) -> None: # documentation is help/epilog/args # noqa: D103
|
|
318
|
+
config: transcrypto.TransConfig = ctx.obj
|
|
319
|
+
base_path: str = transcrypto.RequireKeyPath(config, 'sss')
|
|
320
|
+
subset: list[sss.ShamirSharePrivate] = []
|
|
321
|
+
data_share: sss.ShamirShareData | None = None
|
|
322
|
+
for fname in glob.glob(base_path + '.share.*'): # noqa: PTH207
|
|
323
|
+
share: sss.ShamirSharePrivate = transcrypto.LoadObj(
|
|
324
|
+
fname, config.protect, sss.ShamirSharePrivate
|
|
325
|
+
)
|
|
326
|
+
subset.append(share)
|
|
327
|
+
if isinstance(share, sss.ShamirShareData):
|
|
328
|
+
data_share = share
|
|
329
|
+
config.console.print(f'Loaded SSS share: {fname!r}')
|
|
330
|
+
if data_share is None:
|
|
331
|
+
raise base.InputError('no data share found among the available shares')
|
|
332
|
+
pt: bytes = data_share.RecoverData(subset)
|
|
333
|
+
config.console.print('Secret:')
|
|
334
|
+
config.console.print(transcrypto.BytesToText(pt, config.output_format))
|