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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: validpay
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client
5
5
  Author-email: ValidPay <dev@validpay.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "validpay"
7
- version = "1.1.0"
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.1"
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
- commitment_hash = compute_commitment_hash(plaintext)
104
- encrypted_payload = encrypt(plaintext, full_key)
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": encrypt(plaintext, key),
189
- "commitment_hash": compute_commitment_hash(plaintext),
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
- decrypted = decrypt(data["encrypted_payload"], key)
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
- # Hybrid Commitment Scheme proves the server hasn't swapped the
318
- # ciphertext. Server is blind so it can't forge a matching hash.
319
- # Legacy intents (no hash stored) still verify, just without this check.
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
- full_key = combine_key_shares(share_a, share_b)
438
- decrypted = decrypt(data["encrypted_payload"], full_key)
474
+ # Commitment check over the ciphertext (C-1); legacy v1 skips.
475
+ integrity_verified = _verify_commitment(data)
439
476
 
440
- commitment_hash = data.get("commitment_hash")
441
- integrity_verified = False
442
- if commitment_hash:
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
- integrity_verified = False
682
- commitment_hash = data.get("commitment_hash")
683
- if commitment_hash and role == "full":
684
- all_keys = key_map.get("full", {})
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(plaintext: str) -> str:
79
- """SHA-256 commitment hash of the plaintext payload (Hybrid Commitment Scheme).
80
-
81
- Computed at issuance and stored alongside the ciphertext on the server.
82
- At verification time, the same hash is recomputed against the freshly
83
- decrypted plaintext; a mismatch proves the server tampered with or
84
- swapped the ciphertext, since SHA-256 is one-way and the server cannot
85
- forge a matching hash without the decryption key.
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(plaintext.encode("utf-8")).hexdigest()
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"), None)
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, None)
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 tampered blob",
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: validpay
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client
5
5
  Author-email: ValidPay <dev@validpay.com>
6
6
  License: MIT License
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