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.
- plaincloak/__init__.py +85 -0
- plaincloak/__main__.py +6 -0
- plaincloak/_version.py +1 -0
- plaincloak/api.py +409 -0
- plaincloak/cli/__init__.py +0 -0
- plaincloak/cli/__main__.py +6 -0
- plaincloak/cli/_io.py +576 -0
- plaincloak/cli/cmd_decrypt.py +158 -0
- plaincloak/cli/cmd_encrypt.py +133 -0
- plaincloak/cli/cmd_inspect.py +65 -0
- plaincloak/cli/cmd_keygen.py +126 -0
- plaincloak/cli/cmd_keystore.py +455 -0
- plaincloak/cli/cmd_qr.py +91 -0
- plaincloak/cli/main.py +69 -0
- plaincloak/core/__init__.py +0 -0
- plaincloak/core/base62.py +78 -0
- plaincloak/core/body.py +100 -0
- plaincloak/core/canonical.py +68 -0
- plaincloak/core/compression.py +134 -0
- plaincloak/core/constants.py +15 -0
- plaincloak/core/envelope.py +147 -0
- plaincloak/core/keys.py +177 -0
- plaincloak/core/keystore.py +732 -0
- plaincloak/core/qr.py +146 -0
- plaincloak/core/schemas/algorithms.json +61 -0
- plaincloak/core/schemas/compression.json +32 -0
- plaincloak/core/schemas/keystore.schema.json +127 -0
- plaincloak/core/schemas/message.schema.json +64 -0
- plaincloak/core/suites/__init__.py +33 -0
- plaincloak/core/suites/base.py +146 -0
- plaincloak/core/suites/rsa_oaep_aes256gcm_sha256.py +98 -0
- plaincloak/core/suites/rsa_oaep_sha256.py +64 -0
- plaincloak/exceptions.py +89 -0
- plaincloak/py.typed +0 -0
- plaincloak/types.py +147 -0
- plaincloak-1.0.0.dist-info/METADATA +291 -0
- plaincloak-1.0.0.dist-info/RECORD +40 -0
- plaincloak-1.0.0.dist-info/WHEEL +4 -0
- plaincloak-1.0.0.dist-info/entry_points.txt +2 -0
- 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
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
|