plaincloak 1.0.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.
Files changed (40) hide show
  1. plaincloak/__init__.py +85 -0
  2. plaincloak/__main__.py +6 -0
  3. plaincloak/_version.py +1 -0
  4. plaincloak/api.py +409 -0
  5. plaincloak/cli/__init__.py +0 -0
  6. plaincloak/cli/__main__.py +6 -0
  7. plaincloak/cli/_io.py +576 -0
  8. plaincloak/cli/cmd_decrypt.py +158 -0
  9. plaincloak/cli/cmd_encrypt.py +133 -0
  10. plaincloak/cli/cmd_inspect.py +65 -0
  11. plaincloak/cli/cmd_keygen.py +126 -0
  12. plaincloak/cli/cmd_keystore.py +455 -0
  13. plaincloak/cli/cmd_qr.py +91 -0
  14. plaincloak/cli/main.py +69 -0
  15. plaincloak/core/__init__.py +0 -0
  16. plaincloak/core/base62.py +78 -0
  17. plaincloak/core/body.py +100 -0
  18. plaincloak/core/canonical.py +68 -0
  19. plaincloak/core/compression.py +134 -0
  20. plaincloak/core/constants.py +15 -0
  21. plaincloak/core/envelope.py +147 -0
  22. plaincloak/core/keys.py +177 -0
  23. plaincloak/core/keystore.py +732 -0
  24. plaincloak/core/qr.py +146 -0
  25. plaincloak/core/schemas/algorithms.json +61 -0
  26. plaincloak/core/schemas/compression.json +32 -0
  27. plaincloak/core/schemas/keystore.schema.json +127 -0
  28. plaincloak/core/schemas/message.schema.json +64 -0
  29. plaincloak/core/suites/__init__.py +33 -0
  30. plaincloak/core/suites/base.py +146 -0
  31. plaincloak/core/suites/rsa_oaep_aes256gcm_sha256.py +98 -0
  32. plaincloak/core/suites/rsa_oaep_sha256.py +64 -0
  33. plaincloak/exceptions.py +89 -0
  34. plaincloak/py.typed +0 -0
  35. plaincloak/types.py +147 -0
  36. plaincloak-1.0.0.dist-info/METADATA +291 -0
  37. plaincloak-1.0.0.dist-info/RECORD +40 -0
  38. plaincloak-1.0.0.dist-info/WHEEL +4 -0
  39. plaincloak-1.0.0.dist-info/entry_points.txt +2 -0
  40. plaincloak-1.0.0.dist-info/licenses/LICENSE +213 -0
plaincloak/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ from plaincloak._version import __version__
2
+ from plaincloak.api import (
3
+ decode_qr,
4
+ decrypt,
5
+ encode_qr,
6
+ encrypt,
7
+ generate_keypair,
8
+ key_hash,
9
+ load_private_key_pem,
10
+ load_public_key_pem,
11
+ max_qr_wire_bytes,
12
+ parse_envelope,
13
+ )
14
+ from plaincloak.core.keystore import Keystore
15
+ from plaincloak.exceptions import (
16
+ DecompressedTooLargeError,
17
+ DecompressionFailedError,
18
+ InvalidBase62Error,
19
+ InvalidBodyError,
20
+ InvalidJSONError,
21
+ InvalidKeyError,
22
+ KeystoreError,
23
+ KeystoreFormatError,
24
+ KeystoreLockedError,
25
+ MalformedWireError,
26
+ MessageTooLargeForQRError,
27
+ PlainCloakError,
28
+ PlaintextTooLargeError,
29
+ QRDecodeError,
30
+ QRDependencyMissingError,
31
+ QRError,
32
+ UnknownCompressionError,
33
+ UnknownSuiteError,
34
+ UnsupportedVersionError,
35
+ )
36
+ from plaincloak.types import (
37
+ ContactEntry,
38
+ DecryptResult,
39
+ EnvelopeInfo,
40
+ KeyPair,
41
+ Outcome,
42
+ OwnKeyEntry,
43
+ Suite,
44
+ )
45
+
46
+ __all__ = [
47
+ "__version__",
48
+ "ContactEntry",
49
+ "decode_qr",
50
+ "decrypt",
51
+ "DecryptResult",
52
+ "encode_qr",
53
+ "encrypt",
54
+ "EnvelopeInfo",
55
+ "generate_keypair",
56
+ "key_hash",
57
+ "KeyPair",
58
+ "Keystore",
59
+ "KeystoreError",
60
+ "KeystoreFormatError",
61
+ "KeystoreLockedError",
62
+ "load_private_key_pem",
63
+ "load_public_key_pem",
64
+ "max_qr_wire_bytes",
65
+ "OwnKeyEntry",
66
+ "Outcome",
67
+ "parse_envelope",
68
+ "DecompressedTooLargeError",
69
+ "DecompressionFailedError",
70
+ "InvalidBase62Error",
71
+ "InvalidBodyError",
72
+ "InvalidJSONError",
73
+ "InvalidKeyError",
74
+ "MalformedWireError",
75
+ "MessageTooLargeForQRError",
76
+ "PlainCloakError",
77
+ "PlaintextTooLargeError",
78
+ "QRDecodeError",
79
+ "QRDependencyMissingError",
80
+ "QRError",
81
+ "Suite",
82
+ "UnknownCompressionError",
83
+ "UnknownSuiteError",
84
+ "UnsupportedVersionError",
85
+ ]
plaincloak/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from plaincloak.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
plaincloak/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
plaincloak/api.py ADDED
@@ -0,0 +1,409 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import time
6
+ import unicodedata
7
+ import uuid
8
+ from collections.abc import Mapping, Sequence
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from cryptography.hazmat.primitives.asymmetric.rsa import (
13
+ RSAPrivateKey,
14
+ RSAPublicKey,
15
+ )
16
+
17
+ from plaincloak.core import body, canonical, compression, keys
18
+ from plaincloak.core import qr as _qr
19
+ from plaincloak.core.constants import DEFAULT_DECOMPRESS_BUDGET, WIRE_VERSION_INT
20
+ from plaincloak.core.envelope import format_envelope
21
+ from plaincloak.core.envelope import parse_envelope as _parse_wire
22
+ from plaincloak.core.suites import base as suite_base
23
+ from plaincloak.core.suites import get_suite
24
+ from plaincloak.exceptions import InvalidBodyError, PlaintextTooLargeError
25
+ from plaincloak.types import (
26
+ DecryptResult,
27
+ EnvelopeInfo,
28
+ KeyPair,
29
+ Outcome,
30
+ Suite,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from PIL.Image import Image as QRImage
35
+
36
+ _BODY_SIZE_LIMIT: int = 64 * 1024 # spec section 6.5 practical cap
37
+
38
+
39
+ def generate_keypair(bits: int = 4096) -> KeyPair:
40
+ """Generate a fresh RSA keypair and its SPKI key hash.
41
+
42
+ Args:
43
+ bits (int): Modulus size in bits (2048, 3072, or 4096).
44
+ Defaults to 4096.
45
+
46
+ Raises:
47
+ InvalidKeyError: If `bits` is not a permitted modulus size.
48
+
49
+ Returns:
50
+ KeyPair: Frozen bundle of private key, public key, and key hash.
51
+ """
52
+ private_key = keys.generate_keypair(bits=bits)
53
+ public_key = private_key.public_key()
54
+ return KeyPair(
55
+ private_key=private_key,
56
+ public_key=public_key,
57
+ key_hash=keys.key_hash(public_key),
58
+ )
59
+
60
+
61
+ def load_public_key_pem(pem: str | bytes) -> RSAPublicKey:
62
+ """Load an RSA public key from SPKI PEM.
63
+
64
+ Args:
65
+ pem (str | bytes): SPKI `PUBLIC KEY` PEM.
66
+
67
+ Raises:
68
+ InvalidKeyError: If the PEM is PKCS#1, unparseable, or not RSA.
69
+
70
+ Returns:
71
+ RSAPublicKey: The loaded public key.
72
+ """
73
+ return keys.load_public_key(pem)
74
+
75
+
76
+ def load_private_key_pem(
77
+ pem: str | bytes, password: bytes | None = None
78
+ ) -> RSAPrivateKey:
79
+ """Load an RSA private key from PKCS#8 PEM.
80
+
81
+ Args:
82
+ pem (str | bytes): PKCS#8 `PRIVATE KEY` PEM (optionally encrypted).
83
+ password (bytes | None): Decryption password for an encrypted PEM.
84
+ `None` for an unencrypted key.
85
+
86
+ Raises:
87
+ InvalidKeyError: If the PEM is PKCS#1, unparseable, or not RSA.
88
+
89
+ Returns:
90
+ RSAPrivateKey: The loaded private key.
91
+ """
92
+ return keys.load_private_key(pem, password=password)
93
+
94
+
95
+ def key_hash(key: RSAPublicKey | RSAPrivateKey) -> str:
96
+ """Return the 64-char lowercase hex SHA-256 of the SPKI DER.
97
+
98
+ Args:
99
+ key (RSAPublicKey | RSAPrivateKey): Public or private RSA key. A
100
+ private key is routed through its public half.
101
+
102
+ Returns:
103
+ str: The key hash (spec section 9.2).
104
+ """
105
+ return keys.key_hash(key)
106
+
107
+
108
+ def encrypt(
109
+ plaintext: str,
110
+ *,
111
+ recipient_public_key: RSAPublicKey,
112
+ sender_private_key: RSAPrivateKey,
113
+ suite: Suite = Suite.RSA_OAEP_AES256GCM_SHA256,
114
+ message_id: str | None = None,
115
+ timestamp_ms: int | None = None,
116
+ ) -> str:
117
+ """Produce a wire message per the spec section 10.1 procedure.
118
+
119
+ Args:
120
+ plaintext (str): The user's message. NFC-normalized before encoding.
121
+ recipient_public_key (RSAPublicKey): Recipient encryption key.
122
+ sender_private_key (RSAPrivateKey): Sender signing key.
123
+ suite (Suite): Cryptographic suite. Defaults to the hybrid suite
124
+ (`RSA-OAEP-AES256GCM-SHA256`), which has no plaintext cap.
125
+ message_id (str | None): Override for the body `i` field. When
126
+ `None`, a fresh UUIDv4 is generated. Intended for deterministic
127
+ tests; production callers leave it `None`.
128
+ timestamp_ms (int | None): Override for the body `t` field. When
129
+ `None`, the current Unix time in milliseconds is used.
130
+
131
+ Raises:
132
+ InvalidKeyError: If either key fails the section 8.2 modulus checks.
133
+ PlaintextTooLargeError: If the plaintext exceeds the direct suite's
134
+ `modulus - 66` cap, or the assembled hybrid body exceeds the
135
+ section 6.5 size limit.
136
+
137
+ Returns:
138
+ str: A `PLAINCLOAK:v1:BR:<base62>` wire string.
139
+ """
140
+ suite_impl = get_suite(suite.value)
141
+
142
+ keys.check_rsa_modulus(sender_private_key)
143
+ keys.check_rsa_modulus(recipient_public_key)
144
+
145
+ normalized = unicodedata.normalize("NFC", plaintext)
146
+ message_bytes = normalized.encode("utf-8")
147
+
148
+ cap = suite_impl.max_plaintext_bytes(recipient_public_key)
149
+ if cap is not None and len(message_bytes) > cap:
150
+ raise PlaintextTooLargeError(
151
+ f"plaintext is {len(message_bytes)} bytes; suite {suite.value} "
152
+ f"caps at {cap} bytes for this recipient key"
153
+ )
154
+
155
+ a = suite.value
156
+ i = message_id if message_id is not None else str(uuid.uuid4())
157
+ t = timestamp_ms if timestamp_ms is not None else int(time.time() * 1000)
158
+ s = keys.key_hash(sender_private_key)
159
+ r = keys.key_hash(recipient_public_key)
160
+
161
+ def _aad() -> bytes:
162
+ return canonical.build_aad(
163
+ wire_version_int=WIRE_VERSION_INT, a=a, i=i, t=t, s=s, r=r
164
+ )
165
+
166
+ payload = suite_impl.encrypt_payload(
167
+ message_bytes,
168
+ recipient_public_key=recipient_public_key,
169
+ aad_factory=_aad,
170
+ )
171
+ p = base64.b64encode(payload).decode("ascii")
172
+
173
+ canonical_bytes = canonical.build_canonical(
174
+ wire_version_int=WIRE_VERSION_INT, a=a, i=i, t=t, s=s, r=r, p=p
175
+ )
176
+ signature = suite_base.sign(canonical_bytes, sender_private_key)
177
+ g = base64.b64encode(signature).decode("ascii")
178
+
179
+ message_body = {"a": a, "i": i, "t": t, "s": s, "r": r, "p": p, "g": g}
180
+ body.validate(message_body)
181
+
182
+ serialized = body.serialize(message_body)
183
+ if cap is None and len(serialized) > _BODY_SIZE_LIMIT:
184
+ raise PlaintextTooLargeError(
185
+ f"assembled body is {len(serialized)} bytes; exceeds the "
186
+ f"{_BODY_SIZE_LIMIT}-byte practical limit (spec section 6.5)"
187
+ )
188
+
189
+ compressed = compression.compress(serialized, code="BR")
190
+ return format_envelope(comp_code="BR", payload_bytes=compressed)
191
+
192
+
193
+ def _index_private_keys(
194
+ own_private_keys: Sequence[RSAPrivateKey] | Mapping[str, RSAPrivateKey],
195
+ ) -> dict[str, RSAPrivateKey]:
196
+ """Build a key-hash -> private-key index.
197
+
198
+ Args:
199
+ own_private_keys: Either a sequence of private keys (the hash is
200
+ computed for each) or a mapping already keyed by key hash.
201
+
202
+ Returns:
203
+ dict[str, RSAPrivateKey]: Lookup keyed by SPKI hex digest.
204
+ """
205
+ if isinstance(own_private_keys, Mapping):
206
+ return dict(own_private_keys)
207
+ return {keys.key_hash(pk): pk for pk in own_private_keys}
208
+
209
+
210
+ def decrypt(
211
+ wire: str,
212
+ *,
213
+ own_private_keys: Sequence[RSAPrivateKey] | Mapping[str, RSAPrivateKey],
214
+ trusted_senders: Mapping[str, RSAPublicKey] | None = None,
215
+ decompress_budget_bytes: int = DEFAULT_DECOMPRESS_BUDGET,
216
+ ) -> DecryptResult:
217
+ """Consume a wire message per the spec section 10.2 procedure.
218
+
219
+ Structural failures (bad envelope, decompression, JSON, schema, unknown
220
+ suite) raise a `MalformedWireError` subclass. The five section 10.3
221
+ outcomes are returned in the `DecryptResult`, never raised.
222
+
223
+ Args:
224
+ wire (str): Candidate wire string (already trimmed of channel noise).
225
+ own_private_keys: The consumer's private keys, as a sequence or a
226
+ mapping keyed by key hash.
227
+ trusted_senders (Mapping[str, RSAPublicKey] | None): Trusted sender
228
+ public keys keyed by key hash. Empty/`None` means every message
229
+ resolves to `unknown-sender`.
230
+ decompress_budget_bytes (int): Streaming decompression cap. Defaults
231
+ to 1 MiB.
232
+
233
+ Raises:
234
+ MalformedWireError: For any structural failure of spec section 3.3
235
+ steps (envelope, decompression, JSON, schema, unknown suite).
236
+
237
+ Returns:
238
+ DecryptResult: Outcome plus metadata; plaintext is present only for
239
+ `verified`, `signature-invalid`, and `unknown-sender`.
240
+ """
241
+ senders: Mapping[str, RSAPublicKey] = trusted_senders or {}
242
+
243
+ parsed = _parse_wire(wire)
244
+ decompressed = compression.decompress(
245
+ parsed.payload_bytes,
246
+ code=parsed.comp_code,
247
+ budget_bytes=decompress_budget_bytes,
248
+ )
249
+ message_body = body.parse(decompressed)
250
+ body.validate(message_body)
251
+
252
+ a = message_body["a"]
253
+ i = message_body["i"]
254
+ t = message_body["t"]
255
+ s = message_body["s"]
256
+ r = message_body["r"]
257
+ p = message_body["p"]
258
+ g = message_body["g"]
259
+
260
+ suite_impl = get_suite(a)
261
+
262
+ def _result(outcome: Outcome, plaintext: str | None) -> DecryptResult:
263
+ return DecryptResult(
264
+ outcome=outcome,
265
+ plaintext=plaintext,
266
+ suite=a,
267
+ message_id=i,
268
+ timestamp_ms=t,
269
+ sender_key_hash=s,
270
+ recipient_key_hash=r,
271
+ )
272
+
273
+ private_index = _index_private_keys(own_private_keys)
274
+ recipient_private_key = private_index.get(r)
275
+ if recipient_private_key is None:
276
+ return _result(Outcome.WRONG_RECIPIENT, None)
277
+
278
+ def _aad() -> bytes:
279
+ return canonical.build_aad(
280
+ wire_version_int=parsed.wire_version_int,
281
+ a=a,
282
+ i=i,
283
+ t=t,
284
+ s=s,
285
+ r=r,
286
+ )
287
+
288
+ try:
289
+ payload = base64.b64decode(p, validate=True)
290
+ plaintext_bytes = suite_impl.decrypt_payload(
291
+ payload,
292
+ recipient_private_key=recipient_private_key,
293
+ aad_factory=_aad,
294
+ )
295
+ plaintext = plaintext_bytes.decode("utf-8")
296
+ except (suite_base._DecryptionFailed, UnicodeDecodeError, binascii.Error):
297
+ return _result(Outcome.DECRYPTION_FAILED, None)
298
+
299
+ canonical_bytes = canonical.build_canonical(
300
+ wire_version_int=parsed.wire_version_int,
301
+ a=a,
302
+ i=i,
303
+ t=t,
304
+ s=s,
305
+ r=r,
306
+ p=p,
307
+ )
308
+
309
+ sender_public_key = senders.get(s)
310
+ if sender_public_key is None:
311
+ return _result(Outcome.UNKNOWN_SENDER, plaintext)
312
+
313
+ try:
314
+ signature = base64.b64decode(g, validate=True)
315
+ except binascii.Error:
316
+ return _result(Outcome.SIGNATURE_INVALID, plaintext)
317
+
318
+ if suite_base.verify(canonical_bytes, signature, sender_public_key):
319
+ return _result(Outcome.VERIFIED, plaintext)
320
+ return _result(Outcome.SIGNATURE_INVALID, plaintext)
321
+
322
+
323
+ def encode_qr(wire: str, *, error_correction: str = "M") -> QRImage:
324
+ """Render a wire string as a single QR-code image (optional `[qr]` extra).
325
+
326
+ Args:
327
+ wire (str): The wire string to encode.
328
+ error_correction (str): EC level `L`/`M`/`Q`/`H`. Defaults to `M`.
329
+
330
+ Raises:
331
+ QRDependencyMissingError: If the `[qr]` extra is not installed.
332
+ MessageTooLargeForQRError: If the wire overflows a single QR.
333
+
334
+ Returns:
335
+ QRImage: A Pillow image, ready to `.save(path)`.
336
+ """
337
+ return _qr.encode(wire, error_correction=error_correction)
338
+
339
+
340
+ def decode_qr(image_path: Path) -> str:
341
+ """Decode a wire string from a saved QR image file (optional `[qr]` extra).
342
+
343
+ Args:
344
+ image_path (Path): Path to a saved QR image (PNG / JPG).
345
+
346
+ Raises:
347
+ QRDependencyMissingError: If the `[qr]` decode backend is absent.
348
+ QRDecodeError: If no decodable QR matrix is found.
349
+
350
+ Returns:
351
+ str: The decoded wire string.
352
+ """
353
+ return _qr.decode(image_path)
354
+
355
+
356
+ def max_qr_wire_bytes(error_correction: str = "M") -> int:
357
+ """Return the single-QR wire capacity for an EC level (no extra needed).
358
+
359
+ Args:
360
+ error_correction (str): EC level `L`/`M`/`Q`/`H`. Defaults to `M`.
361
+
362
+ Returns:
363
+ int: Maximum wire length in bytes for a single version-40 QR.
364
+ """
365
+ return _qr.max_wire_bytes(error_correction)
366
+
367
+
368
+ def parse_envelope(wire: str) -> EnvelopeInfo:
369
+ """Return wire metadata without performing any decryption.
370
+
371
+ Runs the spec section 3.3 structural pipeline and body validation, then
372
+ surfaces metadata only. No private key is required or used.
373
+
374
+ Args:
375
+ wire (str): Candidate wire string.
376
+
377
+ Raises:
378
+ MalformedWireError: For any structural failure (envelope,
379
+ decompression, JSON, schema).
380
+
381
+ Returns:
382
+ EnvelopeInfo: Suite, message id, timestamp, key hashes, and the
383
+ payload/signature/body byte lengths.
384
+ """
385
+ parsed = _parse_wire(wire)
386
+ decompressed = compression.decompress(
387
+ parsed.payload_bytes, code=parsed.comp_code
388
+ )
389
+ message_body = body.parse(decompressed)
390
+ body.validate(message_body)
391
+
392
+ try:
393
+ payload_len = len(base64.b64decode(message_body["p"], validate=True))
394
+ signature_len = len(base64.b64decode(message_body["g"], validate=True))
395
+ except binascii.Error as exc:
396
+ raise InvalidBodyError(
397
+ f"body Base64 field could not be decoded: {exc}"
398
+ ) from exc
399
+
400
+ return EnvelopeInfo(
401
+ suite=message_body["a"],
402
+ message_id=message_body["i"],
403
+ timestamp_ms=message_body["t"],
404
+ sender_key_hash=message_body["s"],
405
+ recipient_key_hash=message_body["r"],
406
+ payload_len=payload_len,
407
+ signature_len=signature_len,
408
+ body_len=len(decompressed),
409
+ )
File without changes
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from plaincloak.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()