validpay 1.1.0__tar.gz → 1.2.0__tar.gz
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.
- {validpay-1.1.0/validpay.egg-info → validpay-1.2.0}/PKG-INFO +1 -1
- {validpay-1.1.0 → validpay-1.2.0}/pyproject.toml +1 -1
- {validpay-1.1.0 → validpay-1.2.0}/validpay/__init__.py +3 -1
- {validpay-1.1.0 → validpay-1.2.0}/validpay/client.py +70 -49
- {validpay-1.1.0 → validpay-1.2.0}/validpay/crypto.py +68 -15
- {validpay-1.1.0 → validpay-1.2.0/validpay.egg-info}/PKG-INFO +1 -1
- {validpay-1.1.0 → validpay-1.2.0}/CHANGELOG.md +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/LICENSE +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/MANIFEST.in +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/README.md +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/setup.cfg +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay/_timelock.py +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay/binding.py +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay/errors.py +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay/offline.py +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay/py.typed +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay/types.py +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay.egg-info/SOURCES.txt +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay.egg-info/dependency_links.txt +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay.egg-info/requires.txt +0 -0
- {validpay-1.1.0 → validpay-1.2.0}/validpay.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "validpay"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -14,6 +14,7 @@ from .binding import (
|
|
|
14
14
|
)
|
|
15
15
|
from .client import ValidPayClient
|
|
16
16
|
from .crypto import (
|
|
17
|
+
build_aad,
|
|
17
18
|
build_key_map,
|
|
18
19
|
combine_key_shares,
|
|
19
20
|
compute_commitment_hash,
|
|
@@ -37,6 +38,7 @@ __all__ = [
|
|
|
37
38
|
"encrypt",
|
|
38
39
|
"decrypt",
|
|
39
40
|
"compute_commitment_hash",
|
|
41
|
+
"build_aad",
|
|
40
42
|
"split_key",
|
|
41
43
|
"combine_key_shares",
|
|
42
44
|
"encrypt_fields",
|
|
@@ -49,4 +51,4 @@ __all__ = [
|
|
|
49
51
|
"OfflineVerifyResult",
|
|
50
52
|
]
|
|
51
53
|
|
|
52
|
-
__version__ = "1.0
|
|
54
|
+
__version__ = "1.2.0"
|
|
@@ -10,6 +10,7 @@ import requests
|
|
|
10
10
|
from ._timelock import compute_time_lock_status as _compute_time_lock_status
|
|
11
11
|
from ._timelock import validate_time_lock as _validate_time_lock
|
|
12
12
|
from .crypto import (
|
|
13
|
+
build_aad,
|
|
13
14
|
build_key_map,
|
|
14
15
|
combine_key_shares,
|
|
15
16
|
compute_commitment_hash,
|
|
@@ -27,6 +28,42 @@ DEFAULT_BASE_URL = "https://api.validpay.com"
|
|
|
27
28
|
DEFAULT_TIMEOUT = 30.0
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _verify_commitment(data: Mapping[str, Any]) -> bool:
|
|
32
|
+
"""Version-aware commitment check (Prompt 097 C-1).
|
|
33
|
+
|
|
34
|
+
v2 commitments are SHA-256(ciphertext): recompute over the received
|
|
35
|
+
``encrypted_payload`` and compare. v1 (legacy SHA-256(plaintext)) is a
|
|
36
|
+
confirmation-oracle risk and is intentionally skipped — those documents
|
|
37
|
+
expire naturally. Returns whether integrity was confirmed; raises on a
|
|
38
|
+
v2 mismatch (the ciphertext was swapped after issuance).
|
|
39
|
+
"""
|
|
40
|
+
commitment_hash = data.get("commitment_hash")
|
|
41
|
+
version = data.get("commitment_version", 1)
|
|
42
|
+
if not commitment_hash or not isinstance(version, int) or version < 2:
|
|
43
|
+
return False
|
|
44
|
+
if compute_commitment_hash(data["encrypted_payload"]) != commitment_hash:
|
|
45
|
+
raise ValidPayError(
|
|
46
|
+
"integrity_failure",
|
|
47
|
+
"INTEGRITY VERIFICATION FAILED — the ciphertext does not match the "
|
|
48
|
+
"commitment hash recorded at issuance. This document may have been "
|
|
49
|
+
"tampered with.",
|
|
50
|
+
)
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _aad_for(data: Mapping[str, Any]) -> Optional[str]:
|
|
55
|
+
"""AAD to pass to decrypt (Prompt 097 M-5). For v2 intents, reconstruct it
|
|
56
|
+
from the server-returned metadata so a server that altered document_type
|
|
57
|
+
or the validity window fails the GCM tag check. None for legacy v1."""
|
|
58
|
+
if not isinstance(data.get("encryption_version"), int) or data["encryption_version"] < 2:
|
|
59
|
+
return None
|
|
60
|
+
return build_aad(
|
|
61
|
+
data.get("document_type", ""),
|
|
62
|
+
data.get("valid_from"),
|
|
63
|
+
data.get("valid_until"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
30
67
|
class ValidPayClient:
|
|
31
68
|
"""Client for the ValidPay API.
|
|
32
69
|
|
|
@@ -100,13 +137,17 @@ class ValidPayClient:
|
|
|
100
137
|
result_key, share_b = split_key_fn(full_key)
|
|
101
138
|
|
|
102
139
|
plaintext = json.dumps(payload)
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
# M-5: bind document_type + validity window as AAD.
|
|
141
|
+
aad = build_aad(document_type, valid_from, valid_until)
|
|
142
|
+
encrypted_payload = encrypt(plaintext, full_key, aad)
|
|
143
|
+
# Commitment v2: hash the ciphertext, not the plaintext (C-1).
|
|
144
|
+
commitment_hash = compute_commitment_hash(encrypted_payload)
|
|
105
145
|
|
|
106
146
|
body: Dict[str, Any] = {
|
|
107
147
|
"document_type": document_type,
|
|
108
148
|
"encrypted_payload": encrypted_payload,
|
|
109
149
|
"commitment_hash": commitment_hash,
|
|
150
|
+
"encryption_version": 2,
|
|
110
151
|
}
|
|
111
152
|
if split_key:
|
|
112
153
|
body["split_key"] = True
|
|
@@ -183,10 +224,15 @@ class ValidPayClient:
|
|
|
183
224
|
key = generate_key()
|
|
184
225
|
keys.append(key)
|
|
185
226
|
plaintext = json.dumps(item["payload"])
|
|
227
|
+
# M-5: bind document_type + validity window as AAD per item.
|
|
228
|
+
aad = build_aad(doc_type, valid_from, valid_until)
|
|
229
|
+
encrypted_payload = encrypt(plaintext, key, aad)
|
|
186
230
|
req_item: Dict[str, Any] = {
|
|
187
231
|
"document_type": doc_type,
|
|
188
|
-
"encrypted_payload":
|
|
189
|
-
|
|
232
|
+
"encrypted_payload": encrypted_payload,
|
|
233
|
+
# Commitment v2: hash the ciphertext, not the plaintext (C-1).
|
|
234
|
+
"commitment_hash": compute_commitment_hash(encrypted_payload),
|
|
235
|
+
"encryption_version": 2,
|
|
190
236
|
}
|
|
191
237
|
if valid_from is not None:
|
|
192
238
|
req_item["valid_from"] = valid_from
|
|
@@ -312,23 +358,14 @@ class ValidPayClient:
|
|
|
312
358
|
if data.get("split_key"):
|
|
313
359
|
key = combine_key_shares(key, self._fetch_fragment_b(retrieval_id))
|
|
314
360
|
|
|
315
|
-
|
|
361
|
+
# Commitment check over the ciphertext (C-1) — proves the server
|
|
362
|
+
# hasn't swapped the blob. Done before decryption since it no longer
|
|
363
|
+
# needs the plaintext. Legacy v1 intents skip this check.
|
|
364
|
+
integrity_verified = _verify_commitment(data)
|
|
316
365
|
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
commitment_hash = data.get("commitment_hash")
|
|
321
|
-
integrity_verified = False
|
|
322
|
-
if commitment_hash:
|
|
323
|
-
actual_hash = compute_commitment_hash(decrypted)
|
|
324
|
-
if actual_hash != commitment_hash:
|
|
325
|
-
raise ValidPayError(
|
|
326
|
-
"integrity_failure",
|
|
327
|
-
"INTEGRITY VERIFICATION FAILED — the decrypted payload does not match "
|
|
328
|
-
"the commitment hash stored at issuance. This may indicate server-side "
|
|
329
|
-
"tampering or payload corruption.",
|
|
330
|
-
)
|
|
331
|
-
integrity_verified = True
|
|
366
|
+
# M-5: pass the reconstructed AAD for v2 intents so altered metadata
|
|
367
|
+
# fails the GCM tag check.
|
|
368
|
+
decrypted = decrypt(data["encrypted_payload"], key, _aad_for(data))
|
|
332
369
|
|
|
333
370
|
try:
|
|
334
371
|
payload = json.loads(decrypted)
|
|
@@ -434,20 +471,12 @@ class ValidPayClient:
|
|
|
434
471
|
|
|
435
472
|
share_b = self._fetch_fragment_b(retrieval_id)
|
|
436
473
|
|
|
437
|
-
|
|
438
|
-
|
|
474
|
+
# Commitment check over the ciphertext (C-1); legacy v1 skips.
|
|
475
|
+
integrity_verified = _verify_commitment(data)
|
|
439
476
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
actual_hash = compute_commitment_hash(decrypted)
|
|
444
|
-
if actual_hash != commitment_hash:
|
|
445
|
-
raise ValidPayError(
|
|
446
|
-
"integrity_failure",
|
|
447
|
-
"INTEGRITY VERIFICATION FAILED — the decrypted payload does not match "
|
|
448
|
-
"the commitment hash stored at issuance.",
|
|
449
|
-
)
|
|
450
|
-
integrity_verified = True
|
|
477
|
+
full_key = combine_key_shares(share_a, share_b)
|
|
478
|
+
# M-5: AAD bound for v2 intents.
|
|
479
|
+
decrypted = decrypt(data["encrypted_payload"], full_key, _aad_for(data))
|
|
451
480
|
|
|
452
481
|
try:
|
|
453
482
|
payload = json.loads(decrypted)
|
|
@@ -529,9 +558,11 @@ class ValidPayClient:
|
|
|
529
558
|
key_map = build_key_map(field_keys, disclosure_policy)
|
|
530
559
|
encrypted_key_map = encrypt(json.dumps(key_map), master_key)
|
|
531
560
|
|
|
532
|
-
full_plaintext = json.dumps(payload)
|
|
533
|
-
commitment_hash = compute_commitment_hash(full_plaintext)
|
|
534
561
|
envelope = json.dumps(encrypted_fields)
|
|
562
|
+
# Commitment v2: hash the transported ciphertext envelope, not the
|
|
563
|
+
# plaintext (C-1). Role-independent — the server hashes this exact
|
|
564
|
+
# string and the verifier recomputes it.
|
|
565
|
+
commitment_hash = compute_commitment_hash(envelope)
|
|
535
566
|
|
|
536
567
|
qr_key = master_key
|
|
537
568
|
key_fragment_b: Optional[str] = None
|
|
@@ -678,20 +709,10 @@ class ValidPayClient:
|
|
|
678
709
|
|
|
679
710
|
payload = decrypt_fields(encrypted_fields, field_keys)
|
|
680
711
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
full_payload = decrypt_fields(encrypted_fields, all_keys)
|
|
686
|
-
full_plaintext = json.dumps(full_payload)
|
|
687
|
-
actual_hash = compute_commitment_hash(full_plaintext)
|
|
688
|
-
if actual_hash != commitment_hash:
|
|
689
|
-
raise ValidPayError(
|
|
690
|
-
"integrity_failure",
|
|
691
|
-
"INTEGRITY VERIFICATION FAILED — the decrypted payload does not match "
|
|
692
|
-
"the commitment hash stored at issuance.",
|
|
693
|
-
)
|
|
694
|
-
integrity_verified = True
|
|
712
|
+
# Commitment over the ciphertext envelope (C-1) — role-independent
|
|
713
|
+
# now, since it no longer requires decrypting the full payload.
|
|
714
|
+
# Legacy v1 intents skip this check.
|
|
715
|
+
integrity_verified = _verify_commitment(data)
|
|
695
716
|
|
|
696
717
|
valid_from_str = data.get("valid_from")
|
|
697
718
|
valid_until_str = data.get("valid_until")
|
|
@@ -75,16 +75,20 @@ def combine_key_shares(share_a: str, share_b: str) -> str:
|
|
|
75
75
|
return base64.b64encode(key_bytes).decode("ascii")
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
def compute_commitment_hash(
|
|
79
|
-
"""SHA-256 commitment hash
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
78
|
+
def compute_commitment_hash(ciphertext_b64: str) -> str:
|
|
79
|
+
"""SHA-256 commitment hash over the *ciphertext* blob (commitment v2).
|
|
80
|
+
|
|
81
|
+
Pass the base64 ValidPay wire blob returned by :func:`encrypt` — NOT the
|
|
82
|
+
plaintext. Hashing the ciphertext lets the server publish the commitment
|
|
83
|
+
on the public verify endpoint without creating a confirmation oracle:
|
|
84
|
+
SHA-256(plaintext) over a low-entropy structured document (a check, an
|
|
85
|
+
SSN card) can be brute-forced offline to recover contents without the
|
|
86
|
+
key, which broke the "we cannot read your documents" promise (Prompt 097
|
|
87
|
+
C-1). The commitment still proves the server hasn't swapped the blob
|
|
88
|
+
between issuance and verification — the verifier recomputes
|
|
89
|
+
SHA-256(ciphertext) and compares.
|
|
86
90
|
"""
|
|
87
|
-
return hashlib.sha256(
|
|
91
|
+
return hashlib.sha256(ciphertext_b64.encode("utf-8")).hexdigest()
|
|
88
92
|
|
|
89
93
|
|
|
90
94
|
def _decode_key(key: str) -> bytes:
|
|
@@ -100,26 +104,36 @@ def _decode_key(key: str) -> bytes:
|
|
|
100
104
|
return buf
|
|
101
105
|
|
|
102
106
|
|
|
103
|
-
def encrypt(plaintext: str, key: str) -> str:
|
|
107
|
+
def encrypt(plaintext: str, key: str, aad: str | None = None) -> str:
|
|
104
108
|
"""Encrypt ``plaintext`` (UTF-8) with the given base64 AES-256 key.
|
|
105
109
|
|
|
106
110
|
Returns a base64 string in the ValidPay wire format::
|
|
107
111
|
|
|
108
112
|
base64(iv[12] || authTag[16] || ciphertext)
|
|
113
|
+
|
|
114
|
+
``aad`` (Prompt 097 M-5) is optional Associated Authenticated Data — when
|
|
115
|
+
supplied, the same string MUST be passed to :func:`decrypt` or the GCM tag
|
|
116
|
+
check fails. Use :func:`build_aad` to bind document metadata.
|
|
109
117
|
"""
|
|
110
118
|
key_bytes = _decode_key(key)
|
|
111
119
|
iv = os.urandom(_IV_BYTES)
|
|
112
120
|
aesgcm = AESGCM(key_bytes)
|
|
121
|
+
aad_bytes = aad.encode("utf-8") if aad else None
|
|
113
122
|
# cryptography's AESGCM.encrypt returns ``ciphertext || authTag``.
|
|
114
123
|
# Rearrange to match the ValidPay/Node wire format: iv || authTag || ciphertext.
|
|
115
|
-
ct_with_tag = aesgcm.encrypt(iv, plaintext.encode("utf-8"),
|
|
124
|
+
ct_with_tag = aesgcm.encrypt(iv, plaintext.encode("utf-8"), aad_bytes)
|
|
116
125
|
auth_tag = ct_with_tag[-_TAG_BYTES:]
|
|
117
126
|
ciphertext = ct_with_tag[:-_TAG_BYTES]
|
|
118
127
|
return base64.b64encode(iv + auth_tag + ciphertext).decode("ascii")
|
|
119
128
|
|
|
120
129
|
|
|
121
|
-
def decrypt(blob: str, key: str) -> str:
|
|
122
|
-
"""Decrypt a ValidPay-format base64 blob and return the plaintext (UTF-8).
|
|
130
|
+
def decrypt(blob: str, key: str, aad: str | None = None) -> str:
|
|
131
|
+
"""Decrypt a ValidPay-format base64 blob and return the plaintext (UTF-8).
|
|
132
|
+
|
|
133
|
+
``aad`` must match the value passed to :func:`encrypt` (Prompt 097 M-5);
|
|
134
|
+
a mismatch (e.g. a server that altered the bound metadata) raises
|
|
135
|
+
``decryption_failed``.
|
|
136
|
+
"""
|
|
123
137
|
key_bytes = _decode_key(key)
|
|
124
138
|
|
|
125
139
|
try:
|
|
@@ -138,17 +152,56 @@ def decrypt(blob: str, key: str) -> str:
|
|
|
138
152
|
ciphertext = buf[_IV_BYTES + _TAG_BYTES:]
|
|
139
153
|
|
|
140
154
|
aesgcm = AESGCM(key_bytes)
|
|
155
|
+
aad_bytes = aad.encode("utf-8") if aad else None
|
|
141
156
|
try:
|
|
142
|
-
plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag,
|
|
157
|
+
plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag, aad_bytes)
|
|
143
158
|
except InvalidTag as exc:
|
|
144
159
|
raise ValidPayError(
|
|
145
160
|
"decryption_failed",
|
|
146
|
-
"Decryption failed — wrong key or
|
|
161
|
+
"Decryption failed — wrong key, tampered blob, or altered bound metadata",
|
|
147
162
|
) from exc
|
|
148
163
|
|
|
149
164
|
return plaintext.decode("utf-8")
|
|
150
165
|
|
|
151
166
|
|
|
167
|
+
def build_aad(
|
|
168
|
+
document_type: str,
|
|
169
|
+
valid_from: str | None = None,
|
|
170
|
+
valid_until: str | None = None,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Canonical AAD for AES-GCM metadata binding (Prompt 097 M-5).
|
|
173
|
+
|
|
174
|
+
Binds ``document_type`` and the validity window so a blind/compromised
|
|
175
|
+
server cannot silently alter them (e.g. change "check" to "other"). The
|
|
176
|
+
output MUST be byte-identical across every SDK and the website verifier,
|
|
177
|
+
so:
|
|
178
|
+
|
|
179
|
+
- keys are emitted in a fixed order (document_type, valid_from, valid_until);
|
|
180
|
+
- JSON is compact (no spaces) — matches JS ``JSON.stringify``;
|
|
181
|
+
- timestamps are normalized to **epoch milliseconds** rather than raw
|
|
182
|
+
ISO strings. The server reformats timestamps (``2026-08-01T00:00:00Z``
|
|
183
|
+
becomes ``...:00.000Z``), so binding the raw string would break
|
|
184
|
+
verification of legitimate time-locked documents. Epoch ms parse
|
|
185
|
+
identically on both sides regardless of ISO formatting.
|
|
186
|
+
"""
|
|
187
|
+
payload = {
|
|
188
|
+
"document_type": document_type,
|
|
189
|
+
"valid_from": _epoch_ms(valid_from),
|
|
190
|
+
"valid_until": _epoch_ms(valid_until),
|
|
191
|
+
}
|
|
192
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _epoch_ms(iso: str | None) -> int | None:
|
|
196
|
+
if not iso:
|
|
197
|
+
return None
|
|
198
|
+
from datetime import datetime
|
|
199
|
+
|
|
200
|
+
# Accept the trailing 'Z' (UTC) that fromisoformat rejected before 3.11.
|
|
201
|
+
parsed = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
202
|
+
return int(parsed.timestamp() * 1000)
|
|
203
|
+
|
|
204
|
+
|
|
152
205
|
def encrypt_fields(payload: dict, generate_key_fn=None) -> tuple[dict, dict]:
|
|
153
206
|
"""Encrypt each field in payload separately (Selective Field Disclosure, Patent E).
|
|
154
207
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|