From fac81a7b2fa04750e294b7344f73ceb89fcaafcf Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 12 Jun 2026 04:30:36 -0500 Subject: [PATCH] =?UTF-8?q?feat(094):=201.1.0=20=E2=80=94=20split-key=20by?= =?UTF-8?q?=20default=20in=20create=5Fintent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_intent() gains split_key=True default: returns Share A, sends Share B (key_fragment_b) to the server; split_key=False = legacy flow. - verify_intent() transparently verifies split-key intents (fetches the fragment and XOR-combines) so create -> verify round-trips keep working. - create_split_key_intent() deprecated -> alias of create_intent(). - Version 1.1.0; CHANGELOG + README updated. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 21 ++++++ README.md | 43 ++++++++---- pyproject.toml | 2 +- tests/test_client.py | 66 +++++++++++++++--- validpay/client.py | 162 ++++++++++++++++++++----------------------- 5 files changed, 182 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91327e..8e1e1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to the ValidPay Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-06-12 + +### Changed + +- **Split-key protection (Patent C) is now the default** (Prompt 094). + `create_intent()` splits the AES key into two XOR shares: Share A is + returned as `result.key`, Share B is stored on the ValidPay server. + The full decryption key never exists on any single system after the + call returns. Pass `split_key=False` for the legacy single-key flow. +- `verify_intent()` now verifies split-key intents transparently: when + the API marks an intent `split_key`, it fetches Share B from the + fragment endpoint and XOR-combines it with the key you pass (Share A), + instead of raising `split_key_required`. Legacy intents verify exactly + as before. + +### Deprecated + +- `create_split_key_intent()` — now an alias for `create_intent()` (which + does split-key by default). Emits `DeprecationWarning`; will be removed + in 2.0. + ## [1.0.1] - 2026-06-08 ### Changed diff --git a/README.md b/README.md index b5c22ce..57cf6b7 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,15 @@ client = ValidPayClient(api_key="vp_live_xxx") # Create a single intent — the payload is encrypted locally before # anything leaves your process. Only the ciphertext is sent to ValidPay. +# Split-key protection (Patent C) is the default since 1.1.0: result.key +# is Share A of the AES key; Share B lives on the ValidPay server. The +# full decryption key never exists on any single system. result = client.create_intent( document_type="check", payload={"payee": "John Doe", "amount": 1500.00, "check_number": "10042"}, ) print(result.retrieval_id) # vp_abc123def456 -print(result.key) # base64 AES-256 key — deliver out-of-band +print(result.key) # base64 key Share A — embed in the QR / deliver out-of-band # Create up to 100 intents in one round trip. results = client.create_intent_batch([ @@ -87,26 +90,37 @@ timestamps but never enforces them; this preserves the blind intermediary model (the server never decides whether a document is "still good"). The same `valid_from` / `valid_until` keyword arguments are accepted by -`create_intent_batch` (per-item), `create_split_key_intent`, and -`create_selective_intent`. +`create_intent_batch` (per-item) and `create_selective_intent`. -### Split-key intents (Patent C) +### Split-key intents (Patent C) — the default -Splits the AES key into two XOR shares: Share A is returned to the caller -(typically embedded in the QR code), Share B is stored server-side. Neither -share alone can decrypt the payload. +All documents created with SDK v1.1+ use split-key by default: the AES +key is split into two XOR shares — Share A is returned to the caller +(typically embedded in the QR code), Share B is stored server-side. +Neither share alone can decrypt the payload, so the full decryption key +never exists on any single system. ```python -result = client.create_split_key_intent( +result = client.create_intent( document_type="ssn_card", payload={"ssn": "123-45-6789"}, ) -# result.key is Share A — pair it with Share B at verification time. +# result.key is Share A — verify_intent pairs it with Share B automatically. -verified = client.verify_split_key_intent(result.retrieval_id, result.key) +verified = client.verify_intent(result.retrieval_id, result.key) print(verified.payload) ``` +#### Backward compatibility + +- `create_intent(..., split_key=False)` gives the legacy single-key flow + (the returned `key` is the full AES key). +- `create_split_key_intent()` is a deprecated alias for `create_intent()` + — 1.0.x code keeps working, with a `DeprecationWarning`. +- `verify_intent` detects legacy vs split-key intents from the API + response, so it verifies both; `verify_split_key_intent()` also still + works. + ### Selective disclosure (Patent E) Each field is encrypted with its own per-field key. A disclosure policy maps @@ -151,12 +165,15 @@ for event in history: - `session` — optionally provide a `requests.Session` for connection pooling, custom adapters, or mocking in tests. -### `client.create_intent(document_type, payload) -> CreateIntentResult` +### `client.create_intent(document_type, payload, *, split_key=True) -> CreateIntentResult` Encrypts `payload` (any JSON-serializable value) under a freshly generated AES-256 key and registers it with ValidPay. Returns the -retrieval id and the key. **The key is never sent to ValidPay** — you -must hand it off out-of-band to whoever needs to verify the intent. +retrieval id and the key material: **Share A** of the split key by +default (Share B goes to the server; neither alone decrypts), or the +full AES key with `split_key=False`. **The full key is never sent to +ValidPay** — hand the returned key off out-of-band to whoever needs to +verify the intent. ### `client.create_intent_batch(intents) -> list[CreateIntentResult]` diff --git a/pyproject.toml b/pyproject.toml index 1b094fb..d46b46c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "validpay" -version = "1.0.1" +version = "1.1.0" description = "Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_client.py b/tests/test_client.py index b69d9d3..126da1a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -56,6 +56,9 @@ def test_requires_api_key(): def test_create_intent_encrypts_locally_and_never_sends_key(): + """Since 1.1.0 create_intent defaults to split-key: the result key is + Share A, Share B travels to the server, and neither the full key nor + the plaintext ever appears on the wire.""" session = _FakeSession([ _FakeResponse(201, {"retrieval_id": "vp_abc123def456", "status": "active"}), ]) @@ -82,12 +85,41 @@ def test_create_intent_encrypts_locally_and_never_sends_key(): sent_body = json.loads(call["data"]) assert sent_body["document_type"] == "ssn_card" assert isinstance(sent_body["encrypted_payload"], str) + assert sent_body["split_key"] is True + assert isinstance(sent_body["key_fragment_b"], str) full_call = json.dumps(call) - assert result.key not in full_call + assert result.key not in full_call # Share A never sent assert "123-45-6789" not in full_call assert "Jane Doe" not in full_call + # Share A (returned) XOR Share B (sent) reconstructs the full key. + full_key = combine_key_shares(result.key, sent_body["key_fragment_b"]) + assert full_key not in full_call # full key never on the wire either + decrypted = json.loads(decrypt(sent_body["encrypted_payload"], full_key)) + assert decrypted == payload + + +def test_create_intent_split_key_false_is_legacy_full_key(): + """split_key=False preserves the 1.0.x behavior: no fragment fields, + and the returned key decrypts the payload directly.""" + session = _FakeSession([ + _FakeResponse(201, {"retrieval_id": "vp_legacy1", "status": "active"}), + ]) + client = ValidPayClient( + api_key="test_key", + base_url="https://api.example.test", + session=session, + ) + + payload = {"amount": "100.00"} + result = client.create_intent( + document_type="check", payload=payload, split_key=False, + ) + + sent_body = json.loads(session.calls[0]["data"]) + assert "split_key" not in sent_body + assert "key_fragment_b" not in sent_body decrypted = json.loads(decrypt(sent_body["encrypted_payload"], result.key)) assert decrypted == payload @@ -584,24 +616,38 @@ def test_split_key_round_trip(): assert "Authorization" not in fragment_call["headers"] -def test_verify_intent_rejects_split_key_with_split_key_required(): - real_key = generate_key() - blob = encrypt(json.dumps({"x": 1}), real_key) - session = _FakeSession([ +def test_verify_intent_delegates_to_split_key_flow(): + """Since 1.1.0 verify_intent treats the key as Share A on a split-key + intent and transparently runs the split-key flow, so the natural + create_intent -> verify_intent round trip works.""" + payload = {"x": 1} + + create_session = _FakeSession([ + _FakeResponse(201, {"retrieval_id": "vp_sk_3", "status": "active", "split_key": True}), + ]) + creator = ValidPayClient(api_key="k", base_url="https://api.example.test", session=create_session) + create_result = creator.create_intent(document_type="check", payload=payload) + sent_body = json.loads(create_session.calls[0]["data"]) + + verify_session = _FakeSession([ _FakeResponse(200, { "intent_id": "vp_sk_3", - "encrypted_payload": blob, + "encrypted_payload": sent_body["encrypted_payload"], "issuer": "Acme", "issuer_verified": True, "registered_at": "2026-05-02T00:00:00.000Z", "status": "active", "split_key": True, + "commitment_hash": sent_body["commitment_hash"], }), + _FakeResponse(200, {"intent_id": "vp_sk_3", "fragment_b": sent_body["key_fragment_b"]}), ]) - client = ValidPayClient(api_key="k", base_url="https://api.example.test", session=session) - with pytest.raises(ValidPayError) as exc: - client.verify_intent(retrieval_id="vp_sk_3", key=real_key) - assert exc.value.code == "split_key_required" + verifier = ValidPayClient(api_key="k", base_url="https://api.example.test", session=verify_session) + result = verifier.verify_intent(retrieval_id="vp_sk_3", key=create_result.key) + assert result.payload == payload + assert result.integrity_verified is True + # The delegation fetched the fragment endpoint. + assert verify_session.calls[1]["url"].endswith("/v1/intent/vp_sk_3/fragment") def test_verify_split_key_intent_revoked_raises(): diff --git a/validpay/client.py b/validpay/client.py index 36d2c4d..2cded9d 100644 --- a/validpay/client.py +++ b/validpay/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import warnings from typing import Any, Dict, Iterable, List, Mapping, Optional from urllib.parse import quote @@ -57,9 +58,17 @@ def create_intent( *, valid_from: Optional[str] = None, valid_until: Optional[str] = None, + split_key: bool = True, ) -> CreateIntentResult: """Encrypt ``payload`` locally and register it with the ValidPay API. + As of SDK 1.1.0 this uses **split-key protection (Patent C) by + default**: the AES-256 key is split into two XOR shares — Share A + is returned to you (embed it in the QR code exactly as you would + the key before), Share B is stored on the ValidPay server. The + full decryption key never exists on any single system after this + call returns. + Args: document_type: A short string identifying the document kind (``"check"``, ``"money_order"``, ``"ssn_card"``, etc.). @@ -71,25 +80,37 @@ def create_intent( Verification, blind intermediary preserved). valid_until: Optional ISO-8601 timestamp. The verifier surfaces "expired" status after this time. + split_key: Default ``True``. Set ``False`` for the legacy + single-key flow, where ``key`` in the result is the full + AES key. Verification of legacy intents is unchanged. Returns: A :class:`CreateIntentResult` containing the retrieval id and - the freshly-generated AES key (base64). + the key material (base64): **Share A** when ``split_key=True`` + (the default), the full AES key when ``split_key=False``. """ if not document_type: raise ValidPayError("invalid_argument", "document_type is required") _validate_time_lock(valid_from, valid_until) - key = generate_key() + full_key = generate_key() + share_b: Optional[str] = None + result_key = full_key + if split_key: + result_key, share_b = split_key_fn(full_key) + plaintext = json.dumps(payload) commitment_hash = compute_commitment_hash(plaintext) - encrypted_payload = encrypt(plaintext, key) + encrypted_payload = encrypt(plaintext, full_key) body: Dict[str, Any] = { "document_type": document_type, "encrypted_payload": encrypted_payload, "commitment_hash": commitment_hash, } + if split_key: + body["split_key"] = True + body["key_fragment_b"] = share_b if valid_from is not None: body["valid_from"] = valid_from if valid_until is not None: @@ -110,7 +131,7 @@ def create_intent( details=data, ) - return CreateIntentResult(retrieval_id=retrieval_id, key=key) + return CreateIntentResult(retrieval_id=retrieval_id, key=result_key) def create_intent_batch( self, @@ -200,6 +221,32 @@ def create_intent_batch( out.append(CreateIntentResult(retrieval_id=retrieval_id, key=keys[i])) return out + def _fetch_fragment_b(self, retrieval_id: str) -> str: + """Fetch Share B from the public fragment endpoint (Patent C).""" + fragment_data = self._request( + "GET", + f"/v1/intent/{quote(retrieval_id, safe='')}/fragment", + auth=False, + ) + if isinstance(fragment_data, dict) and fragment_data.get("error"): + raise ValidPayError( + str(fragment_data.get("error")), + f"Fragment retrieval failed: {fragment_data.get('error')}", + details=fragment_data, + ) + share_b = ( + fragment_data.get("fragment_b") + if isinstance(fragment_data, dict) + else None + ) + if not share_b: + raise ValidPayError( + "missing_fragment", + "Server did not return key fragment", + details=fragment_data, + ) + return share_b + def verify_intent( self, retrieval_id: str, @@ -258,15 +305,12 @@ def verify_intent( "Use verify_selective_intent(retrieval_id, key, role) instead of verify_intent().", ) - # Split-Key Verification (Patent C). The caller passed a single key, - # but this intent was issued with a key split into two shares — - # verify_intent doesn't have enough information to reconstruct. + # Split-Key Verification (Patent C). Since 1.1.0 split-key is the + # default issue path, so the key the caller holds is Share A — + # fetch Share B from the fragment endpoint and XOR-combine, so + # create_intent -> verify_intent round-trips keep working. if data.get("split_key"): - raise ValidPayError( - "split_key_required", - f"Intent {retrieval_id} uses split-key protection. " - "Use verify_split_key_intent(retrieval_id, share_a) instead of verify_intent().", - ) + key = combine_key_shares(key, self._fetch_fragment_b(retrieval_id)) decrypted = decrypt(data["encrypted_payload"], key) @@ -319,63 +363,26 @@ def create_split_key_intent( valid_from: Optional[str] = None, valid_until: Optional[str] = None, ) -> CreateIntentResult: - """Create an intent with split-key protection (Patent C). - - The AES-256 key is split into two XOR shares. Share A is returned - to the caller (for embedding in the QR code). Share B is stored on - the ValidPay server. Neither share alone can decrypt the payload — - both are required at verification time. - - The full key exists only transiently in this process; it is never - persisted, never sent over the wire, and never logged. + """Deprecated alias for :meth:`create_intent` (since SDK 1.1.0). - Args: - document_type: A short string identifying the document kind. - payload: Any JSON-serializable object. - - Returns: - A :class:`CreateIntentResult` whose ``key`` is **Share A**, not - the full key. Embed it in the QR code as you would the regular key. + Split-key protection (Patent C) is the default for + ``create_intent`` now, so this method adds nothing. It is kept so + 1.0.x code keeps working; new code should call ``create_intent``. """ - if not document_type: - raise ValidPayError("invalid_argument", "document_type is required") - _validate_time_lock(valid_from, valid_until) - - full_key = generate_key() - share_a, share_b = split_key_fn(full_key) - - plaintext = json.dumps(payload) - commitment_hash = compute_commitment_hash(plaintext) - encrypted_payload = encrypt(plaintext, full_key) - - body: Dict[str, Any] = { - "document_type": document_type, - "encrypted_payload": encrypted_payload, - "commitment_hash": commitment_hash, - "split_key": True, - "key_fragment_b": share_b, - } - if valid_from is not None: - body["valid_from"] = valid_from - if valid_until is not None: - body["valid_until"] = valid_until - - data = self._request( - "POST", - "/v1/intent", - body=body, - auth=True, + warnings.warn( + "create_split_key_intent() is deprecated since validpay 1.1.0: " + "create_intent() uses split-key protection by default. Call " + "create_intent() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.create_intent( + document_type, + payload, + valid_from=valid_from, + valid_until=valid_until, + split_key=True, ) - - retrieval_id = data.get("retrieval_id") if isinstance(data, dict) else None - if not retrieval_id: - raise ValidPayError( - "invalid_response", - "API response missing retrieval_id", - details=data, - ) - - return CreateIntentResult(retrieval_id=retrieval_id, key=share_a) def verify_split_key_intent( self, @@ -425,28 +432,7 @@ def verify_split_key_intent( }, ) - fragment_data = self._request( - "GET", - f"/v1/intent/{quote(retrieval_id, safe='')}/fragment", - auth=False, - ) - if isinstance(fragment_data, dict) and fragment_data.get("error"): - raise ValidPayError( - str(fragment_data.get("error")), - f"Fragment retrieval failed: {fragment_data.get('error')}", - details=fragment_data, - ) - share_b = ( - fragment_data.get("fragment_b") - if isinstance(fragment_data, dict) - else None - ) - if not share_b: - raise ValidPayError( - "missing_fragment", - "Server did not return key fragment", - details=fragment_data, - ) + share_b = self._fetch_fragment_b(retrieval_id) full_key = combine_key_shares(share_a, share_b) decrypted = decrypt(data["encrypted_payload"], full_key)