pqblocks-files 0.1.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.
- pqblocks_files/__init__.py +0 -0
- pqblocks_files/ae.py +92 -0
- pqblocks_files/chunker.py +125 -0
- pqblocks_files/cli.py +240 -0
- pqblocks_files/encrypt.py +337 -0
- pqblocks_files/header.py +350 -0
- pqblocks_files/kem.py +302 -0
- pqblocks_files/keys.py +240 -0
- pqblocks_files/pqtypes.py +48 -0
- pqblocks_files-0.1.0.dist-info/METADATA +140 -0
- pqblocks_files-0.1.0.dist-info/RECORD +16 -0
- pqblocks_files-0.1.0.dist-info/WHEEL +5 -0
- pqblocks_files-0.1.0.dist-info/entry_points.txt +2 -0
- pqblocks_files-0.1.0.dist-info/licenses/LICENSE +201 -0
- pqblocks_files-0.1.0.dist-info/licenses/NOTICE +7 -0
- pqblocks_files-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
pqblocks_files/ae.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""AEAD layer: ChaCha20-Poly1305 and the HKDF key derivations for the pfsh v1 body and header.
|
|
2
|
+
|
|
3
|
+
Contract:
|
|
4
|
+
derive_payload_key(file_key: bytes, chunk_nonce: bytes) -> 32B
|
|
5
|
+
derive_header_mac_key(file_key: bytes) -> 32B
|
|
6
|
+
chacha20poly1305_encrypt(key, nonce, pt, aad=b'') -> ciphertext || tag
|
|
7
|
+
chacha20poly1305_decrypt(key, nonce, ct_tag, aad=b'') -> plaintext
|
|
8
|
+
|
|
9
|
+
Primitives come from pyca/cryptography (audited library).
|
|
10
|
+
HKDF-SHA256 via the same library. No hand-rolled crypto.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from cryptography.exceptions import InvalidTag
|
|
16
|
+
from cryptography.hazmat.primitives import hashes
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
18
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
19
|
+
|
|
20
|
+
from pqblocks_files.pqtypes import TagVerificationFailed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def derive_payload_key(file_key: bytes, chunk_nonce: bytes) -> bytes:
|
|
24
|
+
"""HKDF-SHA256(ikm=file_key, salt=chunk_nonce, info='payload') -> 32B."""
|
|
25
|
+
if len(file_key) != 16:
|
|
26
|
+
raise ValueError(f"file_key must be 16 bytes, got {len(file_key)}")
|
|
27
|
+
if len(chunk_nonce) != 12:
|
|
28
|
+
raise ValueError(f"chunk_nonce must be 12 bytes, got {len(chunk_nonce)}")
|
|
29
|
+
hkdf = HKDF(
|
|
30
|
+
algorithm=hashes.SHA256(),
|
|
31
|
+
length=32,
|
|
32
|
+
salt=chunk_nonce,
|
|
33
|
+
info=b"payload",
|
|
34
|
+
)
|
|
35
|
+
return hkdf.derive(file_key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def derive_header_mac_key(file_key: bytes) -> bytes:
|
|
39
|
+
"""HKDF-SHA256(ikm=file_key, salt=b'', info='header') -> 32B."""
|
|
40
|
+
if len(file_key) != 16:
|
|
41
|
+
raise ValueError(f"file_key must be 16 bytes, got {len(file_key)}")
|
|
42
|
+
hkdf = HKDF(
|
|
43
|
+
algorithm=hashes.SHA256(),
|
|
44
|
+
length=32,
|
|
45
|
+
salt=b"",
|
|
46
|
+
info=b"header",
|
|
47
|
+
)
|
|
48
|
+
return hkdf.derive(file_key)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def chacha20poly1305_encrypt(
|
|
52
|
+
key: bytes,
|
|
53
|
+
nonce: bytes,
|
|
54
|
+
pt: bytes,
|
|
55
|
+
aad: bytes = b"",
|
|
56
|
+
) -> bytes:
|
|
57
|
+
"""Encrypt pt with ChaCha20-Poly1305. Returns ciphertext || tag.
|
|
58
|
+
|
|
59
|
+
key: 32 bytes, nonce: 12 bytes. Output length = len(pt) + 16.
|
|
60
|
+
"""
|
|
61
|
+
if len(key) != 32:
|
|
62
|
+
raise ValueError(f"key must be 32 bytes, got {len(key)}")
|
|
63
|
+
if len(nonce) != 12:
|
|
64
|
+
raise ValueError(f"nonce must be 12 bytes, got {len(nonce)}")
|
|
65
|
+
aead = ChaCha20Poly1305(key)
|
|
66
|
+
return aead.encrypt(nonce, pt, aad)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def chacha20poly1305_decrypt(
|
|
70
|
+
key: bytes,
|
|
71
|
+
nonce: bytes,
|
|
72
|
+
ct_tag: bytes,
|
|
73
|
+
aad: bytes = b"",
|
|
74
|
+
) -> bytes:
|
|
75
|
+
"""Decrypt ct_tag with ChaCha20-Poly1305. Returns plaintext.
|
|
76
|
+
|
|
77
|
+
Raises TagVerificationFailed if the Poly1305 tag does not verify.
|
|
78
|
+
key: 32 bytes, nonce: 12 bytes.
|
|
79
|
+
"""
|
|
80
|
+
if len(key) != 32:
|
|
81
|
+
raise ValueError(f"key must be 32 bytes, got {len(key)}")
|
|
82
|
+
if len(nonce) != 12:
|
|
83
|
+
raise ValueError(f"nonce must be 12 bytes, got {len(nonce)}")
|
|
84
|
+
if len(ct_tag) < 16:
|
|
85
|
+
raise TagVerificationFailed(
|
|
86
|
+
f"ct_tag too short: {len(ct_tag)} bytes (minimum 16 for tag)"
|
|
87
|
+
)
|
|
88
|
+
aead = ChaCha20Poly1305(key)
|
|
89
|
+
try:
|
|
90
|
+
return aead.decrypt(nonce, ct_tag, aad)
|
|
91
|
+
except InvalidTag:
|
|
92
|
+
raise TagVerificationFailed("Poly1305 tag verification failed") from None
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Chunker for the pfsh v1 body.
|
|
2
|
+
|
|
3
|
+
Contract:
|
|
4
|
+
_chunk(plaintext: bytes, file_key: bytes (16B)) -> list[(nonce: bytes (12B), chunk: bytes)]
|
|
5
|
+
_unchunk(chunked: list[(nonce: bytes (12B), chunk: bytes)], file_key: bytes) -> bytes (private: test helper)
|
|
6
|
+
|
|
7
|
+
Where chunk = ciphertext || tag (16B Poly1305 tag)
|
|
8
|
+
- Chunks are 64 KiB max ciphertext
|
|
9
|
+
- Nonce structure (12 bytes):
|
|
10
|
+
- Bytes 0-7: big-endian 64-bit chunk counter (starting at 0)
|
|
11
|
+
- Byte 8: 0x00 (non-final) or 0x01 (final chunk)
|
|
12
|
+
- Bytes 9-11: reserved, MUST be 0x00
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import List, Tuple
|
|
19
|
+
|
|
20
|
+
from cryptography.hazmat.primitives import hashes
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
22
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
23
|
+
|
|
24
|
+
from pqblocks_files.pqtypes import TagVerificationFailed
|
|
25
|
+
|
|
26
|
+
CHUNK_SIZE = 65536 # 64 KiB
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _derive_payload_key(file_key: bytes, chunk_nonce: bytes) -> bytes:
|
|
30
|
+
"""HKDF-SHA256(file_key, salt=chunk_nonce, info="payload") -> 32B."""
|
|
31
|
+
hkdf = HKDF(
|
|
32
|
+
algorithm=hashes.SHA256(),
|
|
33
|
+
length=32,
|
|
34
|
+
salt=chunk_nonce,
|
|
35
|
+
info=b"payload",
|
|
36
|
+
)
|
|
37
|
+
return hkdf.derive(file_key)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_nonce(counter: int, is_final: bool) -> bytes:
|
|
41
|
+
"""Construct 12-byte nonce from counter and final flag."""
|
|
42
|
+
flag = 0x01 if is_final else 0x00
|
|
43
|
+
return counter.to_bytes(8, "big") + bytes([flag]) + b"\x00\x00\x00"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _chunk(plaintext: bytes, file_key: bytes) -> List[Tuple[bytes, bytes]]:
|
|
47
|
+
"""Encrypt plaintext into chunks.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
plaintext: Data to encrypt
|
|
51
|
+
file_key: 16-byte key for HKDF derivation
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of (nonce: 12B, ciphertext||tag: 16B tag) tuples
|
|
55
|
+
|
|
56
|
+
Contract:
|
|
57
|
+
- Number of chunks = ceil(len(plaintext) / 65536)
|
|
58
|
+
- Each chunk max 65536 bytes ciphertext
|
|
59
|
+
- For empty file: 1 chunk with 0-byte ciphertext + 16-byte tag
|
|
60
|
+
- Nonce counter starts at 0, increments per chunk
|
|
61
|
+
- Final chunk has flag=0x01
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(plaintext, bytes):
|
|
64
|
+
raise TypeError(f"plaintext must be bytes, got {type(plaintext).__name__}")
|
|
65
|
+
if len(file_key) != 16:
|
|
66
|
+
raise ValueError(f"file_key must be 16 bytes, got {len(file_key)}")
|
|
67
|
+
|
|
68
|
+
# Handle empty plaintext: 1 chunk with 0-byte ciphertext
|
|
69
|
+
if not plaintext:
|
|
70
|
+
nonce = _make_nonce(0, True)
|
|
71
|
+
key = _derive_payload_key(file_key, nonce)
|
|
72
|
+
aead = ChaCha20Poly1305(key)
|
|
73
|
+
ct_and_tag = aead.encrypt(nonce, b"", b"")
|
|
74
|
+
return [(nonce, ct_and_tag)]
|
|
75
|
+
|
|
76
|
+
# Split into chunks
|
|
77
|
+
num_chunks = (len(plaintext) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
|
78
|
+
|
|
79
|
+
result: List[Tuple[bytes, bytes]] = []
|
|
80
|
+
for i in range(num_chunks):
|
|
81
|
+
is_final = (i == num_chunks - 1)
|
|
82
|
+
nonce = _make_nonce(i, is_final)
|
|
83
|
+
chunk_pt = plaintext[i * CHUNK_SIZE : (i + 1) * CHUNK_SIZE]
|
|
84
|
+
|
|
85
|
+
key = _derive_payload_key(file_key, nonce)
|
|
86
|
+
aead = ChaCha20Poly1305(key)
|
|
87
|
+
ct_and_tag = aead.encrypt(nonce, chunk_pt, b"")
|
|
88
|
+
|
|
89
|
+
result.append((nonce, ct_and_tag))
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _unchunk(
|
|
95
|
+
chunked: List[Tuple[bytes, bytes]], file_key: bytes
|
|
96
|
+
) -> bytes:
|
|
97
|
+
"""Private test helper: decrypt chunks back to plaintext.
|
|
98
|
+
|
|
99
|
+
Trusts the nonces it is given (no order, final-flag or empty-input checks); the public
|
|
100
|
+
decrypt() enforces those. Not part of the API.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
chunked: List of (nonce: 12B, ciphertext||tag) tuples
|
|
104
|
+
file_key: 16-byte key for HKDF derivation
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Original plaintext
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
TagVerificationFailed: If any chunk's Poly1305 tag doesn't verify
|
|
111
|
+
"""
|
|
112
|
+
if not chunked:
|
|
113
|
+
return b""
|
|
114
|
+
|
|
115
|
+
plaintext = b""
|
|
116
|
+
for nonce, ct_and_tag in chunked:
|
|
117
|
+
key = _derive_payload_key(file_key, nonce)
|
|
118
|
+
aead = ChaCha20Poly1305(key)
|
|
119
|
+
try:
|
|
120
|
+
pt = aead.decrypt(nonce, ct_and_tag, b"")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
raise TagVerificationFailed(f"chunk decryption failed: {e}") from e
|
|
123
|
+
plaintext += pt
|
|
124
|
+
|
|
125
|
+
return plaintext
|
pqblocks_files/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
pqblocks-files keygen [--output pub.sec] [--output-sec priv.sec] [--passphrase-file FILE] [--classical]
|
|
4
|
+
pqblocks-files encrypt --recipient pub.sec [--recipient pub2.sec] [--output out.pfsh] [--classical] [-]
|
|
5
|
+
pqblocks-files decrypt --key priv.sec [--passphrase-file FILE] [--allow-classical] [--output out.bin] in.pfsh
|
|
6
|
+
|
|
7
|
+
The private-key passphrase is read from a terminal prompt (getpass) or from --passphrase-file.
|
|
8
|
+
It is never taken from an argument, never has a default, and is never printed.
|
|
9
|
+
|
|
10
|
+
Exit codes:
|
|
11
|
+
0 = Success
|
|
12
|
+
1 = Bad input / malformed file / MAC failure / unusable passphrase
|
|
13
|
+
2 = Key not found in header / private key could not be unlocked
|
|
14
|
+
3 = ML-KEM key-check abort
|
|
15
|
+
4 = File is classical-only (not post-quantum) and --allow-classical was not given
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import getpass
|
|
21
|
+
import sys
|
|
22
|
+
from argparse import ArgumentParser, Namespace
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
from pqblocks_files.encrypt import MODE_CLASSICAL, decrypt_with_mode, encrypt
|
|
26
|
+
from pqblocks_files.keys import (
|
|
27
|
+
keygen,
|
|
28
|
+
load_private_key,
|
|
29
|
+
load_public_key,
|
|
30
|
+
public_key_text,
|
|
31
|
+
save_private_key,
|
|
32
|
+
save_public_key,
|
|
33
|
+
)
|
|
34
|
+
from pqblocks_files.pqtypes import (
|
|
35
|
+
ClassicalOnlyRejected,
|
|
36
|
+
HeaderMACMismatch,
|
|
37
|
+
KeyPair,
|
|
38
|
+
MLKEMKeyCheckFailed,
|
|
39
|
+
Recipient,
|
|
40
|
+
TagVerificationFailed,
|
|
41
|
+
TruncatedFile,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_MAX_PASSPHRASE_FILE = 4096
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_passphrase_file(path: str) -> bytes:
|
|
48
|
+
"""Contents of the file minus one trailing newline. Refuses an empty passphrase."""
|
|
49
|
+
with open(path, "rb") as fh:
|
|
50
|
+
data = fh.read(_MAX_PASSPHRASE_FILE + 1)
|
|
51
|
+
if len(data) > _MAX_PASSPHRASE_FILE:
|
|
52
|
+
raise ValueError(f"passphrase file is larger than {_MAX_PASSPHRASE_FILE} bytes")
|
|
53
|
+
if data.endswith(b"\r\n"):
|
|
54
|
+
data = data[:-2]
|
|
55
|
+
elif data.endswith(b"\n"):
|
|
56
|
+
data = data[:-1]
|
|
57
|
+
if not data:
|
|
58
|
+
raise ValueError("passphrase file is empty")
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_passphrase(args: Namespace, confirm: bool) -> bytes:
|
|
63
|
+
"""Passphrase from --passphrase-file, else an interactive prompt. Never empty."""
|
|
64
|
+
if args.passphrase_file:
|
|
65
|
+
return _read_passphrase_file(args.passphrase_file)
|
|
66
|
+
first = getpass.getpass("Passphrase: ")
|
|
67
|
+
if confirm and getpass.getpass("Repeat passphrase: ") != first:
|
|
68
|
+
raise ValueError("passphrases do not match")
|
|
69
|
+
if not first:
|
|
70
|
+
raise ValueError("passphrase must not be empty")
|
|
71
|
+
return first.encode("utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_keygen(args: Namespace) -> int:
|
|
75
|
+
"""Generate a keypair."""
|
|
76
|
+
passphrase: Optional[bytes] = None
|
|
77
|
+
if args.output_sec:
|
|
78
|
+
try:
|
|
79
|
+
passphrase = _get_passphrase(args, confirm=True)
|
|
80
|
+
except (OSError, ValueError) as exc:
|
|
81
|
+
print(f"Passphrase error: {exc}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
kp = keygen(classical_only=args.classical)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
if args.output_sec:
|
|
87
|
+
save_private_key(kp, args.output_sec, passphrase)
|
|
88
|
+
print(f"Private key written to {args.output_sec}", file=sys.stderr)
|
|
89
|
+
else:
|
|
90
|
+
print("Warning: no --output-sec given, the private key is discarded", file=sys.stderr)
|
|
91
|
+
if args.output:
|
|
92
|
+
save_public_key(kp, args.output)
|
|
93
|
+
print(f"Public key written to {args.output}", file=sys.stderr)
|
|
94
|
+
else:
|
|
95
|
+
print(public_key_text(kp))
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
print(f"Failed to write key file: {exc}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cmd_encrypt(args: Namespace) -> int:
|
|
103
|
+
"""Encrypt a file."""
|
|
104
|
+
recipients: List[Recipient] = []
|
|
105
|
+
for rec_path in args.recipient:
|
|
106
|
+
try:
|
|
107
|
+
recipients.append(load_public_key(rec_path))
|
|
108
|
+
except (OSError, ValueError) as exc:
|
|
109
|
+
print(f"Failed to load recipient {rec_path}: {exc}", file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
if args.input == "-":
|
|
113
|
+
plaintext = sys.stdin.buffer.read()
|
|
114
|
+
else:
|
|
115
|
+
try:
|
|
116
|
+
with open(args.input, "rb") as f:
|
|
117
|
+
plaintext = f.read()
|
|
118
|
+
except FileNotFoundError as exc:
|
|
119
|
+
print(f"Input file not found: {exc}", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
encrypted = encrypt(plaintext, recipients, classical_only=args.classical)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
print(f"Encryption failed: {exc}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
return _write_output(args.output, encrypted, "Encrypted file")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_output(output_path: Optional[str], data: bytes, label: str) -> int:
|
|
132
|
+
if output_path and output_path != "-":
|
|
133
|
+
try:
|
|
134
|
+
with open(output_path, "wb") as f:
|
|
135
|
+
f.write(data)
|
|
136
|
+
print(f"{label} written to {output_path}", file=sys.stderr)
|
|
137
|
+
except IOError as exc:
|
|
138
|
+
print(f"Failed to write output: {exc}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
else:
|
|
141
|
+
sys.stdout.buffer.write(data)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_decrypt(args: Namespace) -> int:
|
|
146
|
+
"""Decrypt a file."""
|
|
147
|
+
from pqblocks_files.kem import HYBRID_PK_LEN
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
passphrase = _get_passphrase(args, confirm=False)
|
|
151
|
+
except (OSError, ValueError) as exc:
|
|
152
|
+
print(f"Passphrase error: {exc}", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
try:
|
|
155
|
+
private = load_private_key(args.key, passphrase)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
print(f"Private key file not found: {args.key}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
print(f"Failed to load key: {exc}", file=sys.stderr)
|
|
161
|
+
return 2
|
|
162
|
+
# decrypt() matches stanzas with the private key only; the public half is a placeholder.
|
|
163
|
+
keypair = KeyPair(public=Recipient(hybrid_pk=b"\x00" * HYBRID_PK_LEN), private=private)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with open(args.input, "rb") as f:
|
|
167
|
+
pfsh_bytes = f.read()
|
|
168
|
+
except FileNotFoundError as exc:
|
|
169
|
+
print(f"Input file not found: {exc}", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
result = decrypt_with_mode(pfsh_bytes, keypair, require_pq=not args.allow_classical)
|
|
174
|
+
except ClassicalOnlyRejected:
|
|
175
|
+
print(
|
|
176
|
+
"File is classical-only (X25519, not post-quantum). "
|
|
177
|
+
"Re-run with --allow-classical to accept it.",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
return 4
|
|
181
|
+
except HeaderMACMismatch:
|
|
182
|
+
print("Key not found in header", file=sys.stderr)
|
|
183
|
+
return 2
|
|
184
|
+
except TagVerificationFailed as exc:
|
|
185
|
+
print(f"Tag verification failed: {exc}", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
except MLKEMKeyCheckFailed as exc:
|
|
188
|
+
print(f"ML-KEM key-check failed: {exc}", file=sys.stderr)
|
|
189
|
+
return 3
|
|
190
|
+
except TruncatedFile as exc:
|
|
191
|
+
print(f"Truncated file: {exc}", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
print(f"Decryption failed: {exc}", file=sys.stderr)
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
if result.mode == MODE_CLASSICAL:
|
|
198
|
+
print("Note: opened through a classical-only (X25519) stanza; not post-quantum protected.",
|
|
199
|
+
file=sys.stderr)
|
|
200
|
+
return _write_output(args.output, result.plaintext, "Decrypted file")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
204
|
+
"""CLI entry point. Returns exit code per contract."""
|
|
205
|
+
parser = ArgumentParser(prog="pqblocks-files")
|
|
206
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
207
|
+
|
|
208
|
+
keygen_parser = subparsers.add_parser("keygen", help="Generate a keypair")
|
|
209
|
+
keygen_parser.add_argument("--output", "-o", help="Public key output path (default: print to stdout)")
|
|
210
|
+
keygen_parser.add_argument("--output-sec", help="Private key output path (encrypted under a passphrase)")
|
|
211
|
+
keygen_parser.add_argument("--passphrase-file", help="Read the private-key passphrase from this file instead of prompting")
|
|
212
|
+
keygen_parser.add_argument("--classical", action="store_true", help="X25519-only key (no post-quantum protection)")
|
|
213
|
+
|
|
214
|
+
encrypt_parser = subparsers.add_parser("encrypt", help="Encrypt a file")
|
|
215
|
+
encrypt_parser.add_argument("--recipient", "-r", required=True, action="append", help="Public key file(s)")
|
|
216
|
+
encrypt_parser.add_argument("--output", "-o", help="Output file path")
|
|
217
|
+
encrypt_parser.add_argument("--classical", action="store_true", help="X25519-only stanzas (no post-quantum protection)")
|
|
218
|
+
encrypt_parser.add_argument("input", help="Input file or '-' for stdin")
|
|
219
|
+
|
|
220
|
+
decrypt_parser = subparsers.add_parser("decrypt", help="Decrypt a file")
|
|
221
|
+
decrypt_parser.add_argument("--key", "-k", required=True, help="Private key file")
|
|
222
|
+
decrypt_parser.add_argument("--passphrase-file", help="Read the private-key passphrase from this file instead of prompting")
|
|
223
|
+
decrypt_parser.add_argument("--allow-classical", action="store_true", help="Accept files that are only readable through a classical-only (X25519) stanza")
|
|
224
|
+
decrypt_parser.add_argument("--output", "-o", help="Output file path")
|
|
225
|
+
decrypt_parser.add_argument("input", help="Input file")
|
|
226
|
+
|
|
227
|
+
args = parser.parse_args(argv)
|
|
228
|
+
|
|
229
|
+
if not args.command:
|
|
230
|
+
parser.print_help(sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
if args.command == "keygen":
|
|
233
|
+
return _cmd_keygen(args)
|
|
234
|
+
if args.command == "encrypt":
|
|
235
|
+
return _cmd_encrypt(args)
|
|
236
|
+
return _cmd_decrypt(args)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|