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.
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())