validpay 1.0.1__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.
@@ -5,6 +5,27 @@ All notable changes to the ValidPay Python SDK will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-06-12
9
+
10
+ ### Changed
11
+
12
+ - **Split-key protection (Patent C) is now the default** (Prompt 094).
13
+ `create_intent()` splits the AES key into two XOR shares: Share A is
14
+ returned as `result.key`, Share B is stored on the ValidPay server.
15
+ The full decryption key never exists on any single system after the
16
+ call returns. Pass `split_key=False` for the legacy single-key flow.
17
+ - `verify_intent()` now verifies split-key intents transparently: when
18
+ the API marks an intent `split_key`, it fetches Share B from the
19
+ fragment endpoint and XOR-combines it with the key you pass (Share A),
20
+ instead of raising `split_key_required`. Legacy intents verify exactly
21
+ as before.
22
+
23
+ ### Deprecated
24
+
25
+ - `create_split_key_intent()` — now an alias for `create_intent()` (which
26
+ does split-key by default). Emits `DeprecationWarning`; will be removed
27
+ in 2.0.
28
+
8
29
  ## [1.0.1] - 2026-06-08
9
30
 
10
31
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: validpay
3
- Version: 1.0.1
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
@@ -89,12 +89,15 @@ client = ValidPayClient(api_key="vp_live_xxx")
89
89
 
90
90
  # Create a single intent — the payload is encrypted locally before
91
91
  # anything leaves your process. Only the ciphertext is sent to ValidPay.
92
+ # Split-key protection (Patent C) is the default since 1.1.0: result.key
93
+ # is Share A of the AES key; Share B lives on the ValidPay server. The
94
+ # full decryption key never exists on any single system.
92
95
  result = client.create_intent(
93
96
  document_type="check",
94
97
  payload={"payee": "John Doe", "amount": 1500.00, "check_number": "10042"},
95
98
  )
96
99
  print(result.retrieval_id) # vp_abc123def456
97
- print(result.key) # base64 AES-256 key — deliver out-of-band
100
+ print(result.key) # base64 key Share A embed in the QR / deliver out-of-band
98
101
 
99
102
  # Create up to 100 intents in one round trip.
100
103
  results = client.create_intent_batch([
@@ -143,26 +146,37 @@ timestamps but never enforces them; this preserves the blind intermediary
143
146
  model (the server never decides whether a document is "still good").
144
147
 
145
148
  The same `valid_from` / `valid_until` keyword arguments are accepted by
146
- `create_intent_batch` (per-item), `create_split_key_intent`, and
147
- `create_selective_intent`.
149
+ `create_intent_batch` (per-item) and `create_selective_intent`.
148
150
 
149
- ### Split-key intents (Patent C)
151
+ ### Split-key intents (Patent C) — the default
150
152
 
151
- Splits the AES key into two XOR shares: Share A is returned to the caller
152
- (typically embedded in the QR code), Share B is stored server-side. Neither
153
- share alone can decrypt the payload.
153
+ All documents created with SDK v1.1+ use split-key by default: the AES
154
+ key is split into two XOR shares — Share A is returned to the caller
155
+ (typically embedded in the QR code), Share B is stored server-side.
156
+ Neither share alone can decrypt the payload, so the full decryption key
157
+ never exists on any single system.
154
158
 
155
159
  ```python
156
- result = client.create_split_key_intent(
160
+ result = client.create_intent(
157
161
  document_type="ssn_card",
158
162
  payload={"ssn": "123-45-6789"},
159
163
  )
160
- # result.key is Share A — pair it with Share B at verification time.
164
+ # result.key is Share A — verify_intent pairs it with Share B automatically.
161
165
 
162
- verified = client.verify_split_key_intent(result.retrieval_id, result.key)
166
+ verified = client.verify_intent(result.retrieval_id, result.key)
163
167
  print(verified.payload)
164
168
  ```
165
169
 
170
+ #### Backward compatibility
171
+
172
+ - `create_intent(..., split_key=False)` gives the legacy single-key flow
173
+ (the returned `key` is the full AES key).
174
+ - `create_split_key_intent()` is a deprecated alias for `create_intent()`
175
+ — 1.0.x code keeps working, with a `DeprecationWarning`.
176
+ - `verify_intent` detects legacy vs split-key intents from the API
177
+ response, so it verifies both; `verify_split_key_intent()` also still
178
+ works.
179
+
166
180
  ### Selective disclosure (Patent E)
167
181
 
168
182
  Each field is encrypted with its own per-field key. A disclosure policy maps
@@ -207,12 +221,15 @@ for event in history:
207
221
  - `session` — optionally provide a `requests.Session` for connection
208
222
  pooling, custom adapters, or mocking in tests.
209
223
 
210
- ### `client.create_intent(document_type, payload) -> CreateIntentResult`
224
+ ### `client.create_intent(document_type, payload, *, split_key=True) -> CreateIntentResult`
211
225
 
212
226
  Encrypts `payload` (any JSON-serializable value) under a freshly
213
227
  generated AES-256 key and registers it with ValidPay. Returns the
214
- retrieval id and the key. **The key is never sent to ValidPay** — you
215
- must hand it off out-of-band to whoever needs to verify the intent.
228
+ retrieval id and the key material: **Share A** of the split key by
229
+ default (Share B goes to the server; neither alone decrypts), or the
230
+ full AES key with `split_key=False`. **The full key is never sent to
231
+ ValidPay** — hand the returned key off out-of-band to whoever needs to
232
+ verify the intent.
216
233
 
217
234
  ### `client.create_intent_batch(intents) -> list[CreateIntentResult]`
218
235
 
@@ -33,12 +33,15 @@ client = ValidPayClient(api_key="vp_live_xxx")
33
33
 
34
34
  # Create a single intent — the payload is encrypted locally before
35
35
  # anything leaves your process. Only the ciphertext is sent to ValidPay.
36
+ # Split-key protection (Patent C) is the default since 1.1.0: result.key
37
+ # is Share A of the AES key; Share B lives on the ValidPay server. The
38
+ # full decryption key never exists on any single system.
36
39
  result = client.create_intent(
37
40
  document_type="check",
38
41
  payload={"payee": "John Doe", "amount": 1500.00, "check_number": "10042"},
39
42
  )
40
43
  print(result.retrieval_id) # vp_abc123def456
41
- print(result.key) # base64 AES-256 key — deliver out-of-band
44
+ print(result.key) # base64 key Share A embed in the QR / deliver out-of-band
42
45
 
43
46
  # Create up to 100 intents in one round trip.
44
47
  results = client.create_intent_batch([
@@ -87,26 +90,37 @@ timestamps but never enforces them; this preserves the blind intermediary
87
90
  model (the server never decides whether a document is "still good").
88
91
 
89
92
  The same `valid_from` / `valid_until` keyword arguments are accepted by
90
- `create_intent_batch` (per-item), `create_split_key_intent`, and
91
- `create_selective_intent`.
93
+ `create_intent_batch` (per-item) and `create_selective_intent`.
92
94
 
93
- ### Split-key intents (Patent C)
95
+ ### Split-key intents (Patent C) — the default
94
96
 
95
- Splits the AES key into two XOR shares: Share A is returned to the caller
96
- (typically embedded in the QR code), Share B is stored server-side. Neither
97
- share alone can decrypt the payload.
97
+ All documents created with SDK v1.1+ use split-key by default: the AES
98
+ key is split into two XOR shares — Share A is returned to the caller
99
+ (typically embedded in the QR code), Share B is stored server-side.
100
+ Neither share alone can decrypt the payload, so the full decryption key
101
+ never exists on any single system.
98
102
 
99
103
  ```python
100
- result = client.create_split_key_intent(
104
+ result = client.create_intent(
101
105
  document_type="ssn_card",
102
106
  payload={"ssn": "123-45-6789"},
103
107
  )
104
- # result.key is Share A — pair it with Share B at verification time.
108
+ # result.key is Share A — verify_intent pairs it with Share B automatically.
105
109
 
106
- verified = client.verify_split_key_intent(result.retrieval_id, result.key)
110
+ verified = client.verify_intent(result.retrieval_id, result.key)
107
111
  print(verified.payload)
108
112
  ```
109
113
 
114
+ #### Backward compatibility
115
+
116
+ - `create_intent(..., split_key=False)` gives the legacy single-key flow
117
+ (the returned `key` is the full AES key).
118
+ - `create_split_key_intent()` is a deprecated alias for `create_intent()`
119
+ — 1.0.x code keeps working, with a `DeprecationWarning`.
120
+ - `verify_intent` detects legacy vs split-key intents from the API
121
+ response, so it verifies both; `verify_split_key_intent()` also still
122
+ works.
123
+
110
124
  ### Selective disclosure (Patent E)
111
125
 
112
126
  Each field is encrypted with its own per-field key. A disclosure policy maps
@@ -151,12 +165,15 @@ for event in history:
151
165
  - `session` — optionally provide a `requests.Session` for connection
152
166
  pooling, custom adapters, or mocking in tests.
153
167
 
154
- ### `client.create_intent(document_type, payload) -> CreateIntentResult`
168
+ ### `client.create_intent(document_type, payload, *, split_key=True) -> CreateIntentResult`
155
169
 
156
170
  Encrypts `payload` (any JSON-serializable value) under a freshly
157
171
  generated AES-256 key and registers it with ValidPay. Returns the
158
- retrieval id and the key. **The key is never sent to ValidPay** — you
159
- must hand it off out-of-band to whoever needs to verify the intent.
172
+ retrieval id and the key material: **Share A** of the split key by
173
+ default (Share B goes to the server; neither alone decrypts), or the
174
+ full AES key with `split_key=False`. **The full key is never sent to
175
+ ValidPay** — hand the returned key off out-of-band to whoever needs to
176
+ verify the intent.
160
177
 
161
178
  ### `client.create_intent_batch(intents) -> list[CreateIntentResult]`
162
179
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "validpay"
7
- version = "1.0.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.1"
54
+ __version__ = "1.2.0"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import warnings
4
5
  from typing import Any, Dict, Iterable, List, Mapping, Optional
5
6
  from urllib.parse import quote
6
7
 
@@ -9,6 +10,7 @@ import requests
9
10
  from ._timelock import compute_time_lock_status as _compute_time_lock_status
10
11
  from ._timelock import validate_time_lock as _validate_time_lock
11
12
  from .crypto import (
13
+ build_aad,
12
14
  build_key_map,
13
15
  combine_key_shares,
14
16
  compute_commitment_hash,
@@ -26,6 +28,42 @@ DEFAULT_BASE_URL = "https://api.validpay.com"
26
28
  DEFAULT_TIMEOUT = 30.0
27
29
 
28
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
+
29
67
  class ValidPayClient:
30
68
  """Client for the ValidPay API.
31
69
 
@@ -57,9 +95,17 @@ class ValidPayClient:
57
95
  *,
58
96
  valid_from: Optional[str] = None,
59
97
  valid_until: Optional[str] = None,
98
+ split_key: bool = True,
60
99
  ) -> CreateIntentResult:
61
100
  """Encrypt ``payload`` locally and register it with the ValidPay API.
62
101
 
102
+ As of SDK 1.1.0 this uses **split-key protection (Patent C) by
103
+ default**: the AES-256 key is split into two XOR shares — Share A
104
+ is returned to you (embed it in the QR code exactly as you would
105
+ the key before), Share B is stored on the ValidPay server. The
106
+ full decryption key never exists on any single system after this
107
+ call returns.
108
+
63
109
  Args:
64
110
  document_type: A short string identifying the document kind
65
111
  (``"check"``, ``"money_order"``, ``"ssn_card"``, etc.).
@@ -71,25 +117,41 @@ class ValidPayClient:
71
117
  Verification, blind intermediary preserved).
72
118
  valid_until: Optional ISO-8601 timestamp. The verifier surfaces
73
119
  "expired" status after this time.
120
+ split_key: Default ``True``. Set ``False`` for the legacy
121
+ single-key flow, where ``key`` in the result is the full
122
+ AES key. Verification of legacy intents is unchanged.
74
123
 
75
124
  Returns:
76
125
  A :class:`CreateIntentResult` containing the retrieval id and
77
- the freshly-generated AES key (base64).
126
+ the key material (base64): **Share A** when ``split_key=True``
127
+ (the default), the full AES key when ``split_key=False``.
78
128
  """
79
129
  if not document_type:
80
130
  raise ValidPayError("invalid_argument", "document_type is required")
81
131
  _validate_time_lock(valid_from, valid_until)
82
132
 
83
- key = generate_key()
133
+ full_key = generate_key()
134
+ share_b: Optional[str] = None
135
+ result_key = full_key
136
+ if split_key:
137
+ result_key, share_b = split_key_fn(full_key)
138
+
84
139
  plaintext = json.dumps(payload)
85
- commitment_hash = compute_commitment_hash(plaintext)
86
- encrypted_payload = encrypt(plaintext, 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)
87
145
 
88
146
  body: Dict[str, Any] = {
89
147
  "document_type": document_type,
90
148
  "encrypted_payload": encrypted_payload,
91
149
  "commitment_hash": commitment_hash,
150
+ "encryption_version": 2,
92
151
  }
152
+ if split_key:
153
+ body["split_key"] = True
154
+ body["key_fragment_b"] = share_b
93
155
  if valid_from is not None:
94
156
  body["valid_from"] = valid_from
95
157
  if valid_until is not None:
@@ -110,7 +172,7 @@ class ValidPayClient:
110
172
  details=data,
111
173
  )
112
174
 
113
- return CreateIntentResult(retrieval_id=retrieval_id, key=key)
175
+ return CreateIntentResult(retrieval_id=retrieval_id, key=result_key)
114
176
 
115
177
  def create_intent_batch(
116
178
  self,
@@ -162,10 +224,15 @@ class ValidPayClient:
162
224
  key = generate_key()
163
225
  keys.append(key)
164
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)
165
230
  req_item: Dict[str, Any] = {
166
231
  "document_type": doc_type,
167
- "encrypted_payload": encrypt(plaintext, key),
168
- "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,
169
236
  }
170
237
  if valid_from is not None:
171
238
  req_item["valid_from"] = valid_from
@@ -200,6 +267,32 @@ class ValidPayClient:
200
267
  out.append(CreateIntentResult(retrieval_id=retrieval_id, key=keys[i]))
201
268
  return out
202
269
 
270
+ def _fetch_fragment_b(self, retrieval_id: str) -> str:
271
+ """Fetch Share B from the public fragment endpoint (Patent C)."""
272
+ fragment_data = self._request(
273
+ "GET",
274
+ f"/v1/intent/{quote(retrieval_id, safe='')}/fragment",
275
+ auth=False,
276
+ )
277
+ if isinstance(fragment_data, dict) and fragment_data.get("error"):
278
+ raise ValidPayError(
279
+ str(fragment_data.get("error")),
280
+ f"Fragment retrieval failed: {fragment_data.get('error')}",
281
+ details=fragment_data,
282
+ )
283
+ share_b = (
284
+ fragment_data.get("fragment_b")
285
+ if isinstance(fragment_data, dict)
286
+ else None
287
+ )
288
+ if not share_b:
289
+ raise ValidPayError(
290
+ "missing_fragment",
291
+ "Server did not return key fragment",
292
+ details=fragment_data,
293
+ )
294
+ return share_b
295
+
203
296
  def verify_intent(
204
297
  self,
205
298
  retrieval_id: str,
@@ -258,33 +351,21 @@ class ValidPayClient:
258
351
  "Use verify_selective_intent(retrieval_id, key, role) instead of verify_intent().",
259
352
  )
260
353
 
261
- # Split-Key Verification (Patent C). The caller passed a single key,
262
- # but this intent was issued with a key split into two shares
263
- # verify_intent doesn't have enough information to reconstruct.
354
+ # Split-Key Verification (Patent C). Since 1.1.0 split-key is the
355
+ # default issue path, so the key the caller holds is Share A
356
+ # fetch Share B from the fragment endpoint and XOR-combine, so
357
+ # create_intent -> verify_intent round-trips keep working.
264
358
  if data.get("split_key"):
265
- raise ValidPayError(
266
- "split_key_required",
267
- f"Intent {retrieval_id} uses split-key protection. "
268
- "Use verify_split_key_intent(retrieval_id, share_a) instead of verify_intent().",
269
- )
359
+ key = combine_key_shares(key, self._fetch_fragment_b(retrieval_id))
270
360
 
271
- 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)
272
365
 
273
- # Hybrid Commitment Scheme proves the server hasn't swapped the
274
- # ciphertext. Server is blind so it can't forge a matching hash.
275
- # Legacy intents (no hash stored) still verify, just without this check.
276
- commitment_hash = data.get("commitment_hash")
277
- integrity_verified = False
278
- if commitment_hash:
279
- actual_hash = compute_commitment_hash(decrypted)
280
- if actual_hash != commitment_hash:
281
- raise ValidPayError(
282
- "integrity_failure",
283
- "INTEGRITY VERIFICATION FAILED — the decrypted payload does not match "
284
- "the commitment hash stored at issuance. This may indicate server-side "
285
- "tampering or payload corruption.",
286
- )
287
- 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))
288
369
 
289
370
  try:
290
371
  payload = json.loads(decrypted)
@@ -319,63 +400,26 @@ class ValidPayClient:
319
400
  valid_from: Optional[str] = None,
320
401
  valid_until: Optional[str] = None,
321
402
  ) -> CreateIntentResult:
322
- """Create an intent with split-key protection (Patent C).
323
-
324
- The AES-256 key is split into two XOR shares. Share A is returned
325
- to the caller (for embedding in the QR code). Share B is stored on
326
- the ValidPay server. Neither share alone can decrypt the payload —
327
- both are required at verification time.
403
+ """Deprecated alias for :meth:`create_intent` (since SDK 1.1.0).
328
404
 
329
- The full key exists only transiently in this process; it is never
330
- persisted, never sent over the wire, and never logged.
331
-
332
- Args:
333
- document_type: A short string identifying the document kind.
334
- payload: Any JSON-serializable object.
335
-
336
- Returns:
337
- A :class:`CreateIntentResult` whose ``key`` is **Share A**, not
338
- the full key. Embed it in the QR code as you would the regular key.
405
+ Split-key protection (Patent C) is the default for
406
+ ``create_intent`` now, so this method adds nothing. It is kept so
407
+ 1.0.x code keeps working; new code should call ``create_intent``.
339
408
  """
340
- if not document_type:
341
- raise ValidPayError("invalid_argument", "document_type is required")
342
- _validate_time_lock(valid_from, valid_until)
343
-
344
- full_key = generate_key()
345
- share_a, share_b = split_key_fn(full_key)
346
-
347
- plaintext = json.dumps(payload)
348
- commitment_hash = compute_commitment_hash(plaintext)
349
- encrypted_payload = encrypt(plaintext, full_key)
350
-
351
- body: Dict[str, Any] = {
352
- "document_type": document_type,
353
- "encrypted_payload": encrypted_payload,
354
- "commitment_hash": commitment_hash,
355
- "split_key": True,
356
- "key_fragment_b": share_b,
357
- }
358
- if valid_from is not None:
359
- body["valid_from"] = valid_from
360
- if valid_until is not None:
361
- body["valid_until"] = valid_until
362
-
363
- data = self._request(
364
- "POST",
365
- "/v1/intent",
366
- body=body,
367
- auth=True,
409
+ warnings.warn(
410
+ "create_split_key_intent() is deprecated since validpay 1.1.0: "
411
+ "create_intent() uses split-key protection by default. Call "
412
+ "create_intent() instead.",
413
+ DeprecationWarning,
414
+ stacklevel=2,
415
+ )
416
+ return self.create_intent(
417
+ document_type,
418
+ payload,
419
+ valid_from=valid_from,
420
+ valid_until=valid_until,
421
+ split_key=True,
368
422
  )
369
-
370
- retrieval_id = data.get("retrieval_id") if isinstance(data, dict) else None
371
- if not retrieval_id:
372
- raise ValidPayError(
373
- "invalid_response",
374
- "API response missing retrieval_id",
375
- details=data,
376
- )
377
-
378
- return CreateIntentResult(retrieval_id=retrieval_id, key=share_a)
379
423
 
380
424
  def verify_split_key_intent(
381
425
  self,
@@ -425,43 +469,14 @@ class ValidPayClient:
425
469
  },
426
470
  )
427
471
 
428
- fragment_data = self._request(
429
- "GET",
430
- f"/v1/intent/{quote(retrieval_id, safe='')}/fragment",
431
- auth=False,
432
- )
433
- if isinstance(fragment_data, dict) and fragment_data.get("error"):
434
- raise ValidPayError(
435
- str(fragment_data.get("error")),
436
- f"Fragment retrieval failed: {fragment_data.get('error')}",
437
- details=fragment_data,
438
- )
439
- share_b = (
440
- fragment_data.get("fragment_b")
441
- if isinstance(fragment_data, dict)
442
- else None
443
- )
444
- if not share_b:
445
- raise ValidPayError(
446
- "missing_fragment",
447
- "Server did not return key fragment",
448
- details=fragment_data,
449
- )
472
+ share_b = self._fetch_fragment_b(retrieval_id)
450
473
 
451
- full_key = combine_key_shares(share_a, share_b)
452
- decrypted = decrypt(data["encrypted_payload"], full_key)
474
+ # Commitment check over the ciphertext (C-1); legacy v1 skips.
475
+ integrity_verified = _verify_commitment(data)
453
476
 
454
- commitment_hash = data.get("commitment_hash")
455
- integrity_verified = False
456
- if commitment_hash:
457
- actual_hash = compute_commitment_hash(decrypted)
458
- if actual_hash != commitment_hash:
459
- raise ValidPayError(
460
- "integrity_failure",
461
- "INTEGRITY VERIFICATION FAILED — the decrypted payload does not match "
462
- "the commitment hash stored at issuance.",
463
- )
464
- 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))
465
480
 
466
481
  try:
467
482
  payload = json.loads(decrypted)
@@ -543,9 +558,11 @@ class ValidPayClient:
543
558
  key_map = build_key_map(field_keys, disclosure_policy)
544
559
  encrypted_key_map = encrypt(json.dumps(key_map), master_key)
545
560
 
546
- full_plaintext = json.dumps(payload)
547
- commitment_hash = compute_commitment_hash(full_plaintext)
548
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)
549
566
 
550
567
  qr_key = master_key
551
568
  key_fragment_b: Optional[str] = None
@@ -692,20 +709,10 @@ class ValidPayClient:
692
709
 
693
710
  payload = decrypt_fields(encrypted_fields, field_keys)
694
711
 
695
- integrity_verified = False
696
- commitment_hash = data.get("commitment_hash")
697
- if commitment_hash and role == "full":
698
- all_keys = key_map.get("full", {})
699
- full_payload = decrypt_fields(encrypted_fields, all_keys)
700
- full_plaintext = json.dumps(full_payload)
701
- actual_hash = compute_commitment_hash(full_plaintext)
702
- if actual_hash != commitment_hash:
703
- raise ValidPayError(
704
- "integrity_failure",
705
- "INTEGRITY VERIFICATION FAILED — the decrypted payload does not match "
706
- "the commitment hash stored at issuance.",
707
- )
708
- 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)
709
716
 
710
717
  valid_from_str = data.get("valid_from")
711
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.0.1
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
@@ -89,12 +89,15 @@ client = ValidPayClient(api_key="vp_live_xxx")
89
89
 
90
90
  # Create a single intent — the payload is encrypted locally before
91
91
  # anything leaves your process. Only the ciphertext is sent to ValidPay.
92
+ # Split-key protection (Patent C) is the default since 1.1.0: result.key
93
+ # is Share A of the AES key; Share B lives on the ValidPay server. The
94
+ # full decryption key never exists on any single system.
92
95
  result = client.create_intent(
93
96
  document_type="check",
94
97
  payload={"payee": "John Doe", "amount": 1500.00, "check_number": "10042"},
95
98
  )
96
99
  print(result.retrieval_id) # vp_abc123def456
97
- print(result.key) # base64 AES-256 key — deliver out-of-band
100
+ print(result.key) # base64 key Share A embed in the QR / deliver out-of-band
98
101
 
99
102
  # Create up to 100 intents in one round trip.
100
103
  results = client.create_intent_batch([
@@ -143,26 +146,37 @@ timestamps but never enforces them; this preserves the blind intermediary
143
146
  model (the server never decides whether a document is "still good").
144
147
 
145
148
  The same `valid_from` / `valid_until` keyword arguments are accepted by
146
- `create_intent_batch` (per-item), `create_split_key_intent`, and
147
- `create_selective_intent`.
149
+ `create_intent_batch` (per-item) and `create_selective_intent`.
148
150
 
149
- ### Split-key intents (Patent C)
151
+ ### Split-key intents (Patent C) — the default
150
152
 
151
- Splits the AES key into two XOR shares: Share A is returned to the caller
152
- (typically embedded in the QR code), Share B is stored server-side. Neither
153
- share alone can decrypt the payload.
153
+ All documents created with SDK v1.1+ use split-key by default: the AES
154
+ key is split into two XOR shares — Share A is returned to the caller
155
+ (typically embedded in the QR code), Share B is stored server-side.
156
+ Neither share alone can decrypt the payload, so the full decryption key
157
+ never exists on any single system.
154
158
 
155
159
  ```python
156
- result = client.create_split_key_intent(
160
+ result = client.create_intent(
157
161
  document_type="ssn_card",
158
162
  payload={"ssn": "123-45-6789"},
159
163
  )
160
- # result.key is Share A — pair it with Share B at verification time.
164
+ # result.key is Share A — verify_intent pairs it with Share B automatically.
161
165
 
162
- verified = client.verify_split_key_intent(result.retrieval_id, result.key)
166
+ verified = client.verify_intent(result.retrieval_id, result.key)
163
167
  print(verified.payload)
164
168
  ```
165
169
 
170
+ #### Backward compatibility
171
+
172
+ - `create_intent(..., split_key=False)` gives the legacy single-key flow
173
+ (the returned `key` is the full AES key).
174
+ - `create_split_key_intent()` is a deprecated alias for `create_intent()`
175
+ — 1.0.x code keeps working, with a `DeprecationWarning`.
176
+ - `verify_intent` detects legacy vs split-key intents from the API
177
+ response, so it verifies both; `verify_split_key_intent()` also still
178
+ works.
179
+
166
180
  ### Selective disclosure (Patent E)
167
181
 
168
182
  Each field is encrypted with its own per-field key. A disclosure policy maps
@@ -207,12 +221,15 @@ for event in history:
207
221
  - `session` — optionally provide a `requests.Session` for connection
208
222
  pooling, custom adapters, or mocking in tests.
209
223
 
210
- ### `client.create_intent(document_type, payload) -> CreateIntentResult`
224
+ ### `client.create_intent(document_type, payload, *, split_key=True) -> CreateIntentResult`
211
225
 
212
226
  Encrypts `payload` (any JSON-serializable value) under a freshly
213
227
  generated AES-256 key and registers it with ValidPay. Returns the
214
- retrieval id and the key. **The key is never sent to ValidPay** — you
215
- must hand it off out-of-band to whoever needs to verify the intent.
228
+ retrieval id and the key material: **Share A** of the split key by
229
+ default (Share B goes to the server; neither alone decrypts), or the
230
+ full AES key with `split_key=False`. **The full key is never sent to
231
+ ValidPay** — hand the returned key off out-of-band to whoever needs to
232
+ verify the intent.
216
233
 
217
234
  ### `client.create_intent_batch(intents) -> list[CreateIntentResult]`
218
235
 
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