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.
- {validpay-1.0.1 → validpay-1.2.0}/CHANGELOG.md +21 -0
- {validpay-1.0.1/validpay.egg-info → validpay-1.2.0}/PKG-INFO +31 -14
- {validpay-1.0.1 → validpay-1.2.0}/README.md +30 -13
- {validpay-1.0.1 → validpay-1.2.0}/pyproject.toml +1 -1
- {validpay-1.0.1 → validpay-1.2.0}/validpay/__init__.py +3 -1
- {validpay-1.0.1 → validpay-1.2.0}/validpay/client.py +143 -136
- {validpay-1.0.1 → validpay-1.2.0}/validpay/crypto.py +68 -15
- {validpay-1.0.1 → validpay-1.2.0/validpay.egg-info}/PKG-INFO +31 -14
- {validpay-1.0.1 → validpay-1.2.0}/LICENSE +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/MANIFEST.in +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/setup.cfg +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay/_timelock.py +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay/binding.py +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay/errors.py +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay/offline.py +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay/py.typed +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay/types.py +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay.egg-info/SOURCES.txt +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay.egg-info/dependency_links.txt +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay.egg-info/requires.txt +0 -0
- {validpay-1.0.1 → validpay-1.2.0}/validpay.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
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
|
|
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)
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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 —
|
|
164
|
+
# result.key is Share A — verify_intent pairs it with Share B automatically.
|
|
161
165
|
|
|
162
|
-
verified = client.
|
|
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
|
|
215
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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 —
|
|
108
|
+
# result.key is Share A — verify_intent pairs it with Share B automatically.
|
|
105
109
|
|
|
106
|
-
verified = client.
|
|
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
|
|
159
|
-
|
|
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
|
|
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"
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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=
|
|
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":
|
|
168
|
-
|
|
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).
|
|
262
|
-
#
|
|
263
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
274
|
-
#
|
|
275
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
474
|
+
# Commitment check over the ciphertext (C-1); legacy v1 skips.
|
|
475
|
+
integrity_verified = _verify_commitment(data)
|
|
453
476
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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(
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: validpay
|
|
3
|
-
Version: 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
|
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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 —
|
|
164
|
+
# result.key is Share A — verify_intent pairs it with Share B automatically.
|
|
161
165
|
|
|
162
|
-
verified = client.
|
|
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
|
|
215
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|