Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 30 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]`

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
66 changes: 56 additions & 10 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}),
])
Expand All @@ -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

Expand Down Expand Up @@ -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():
Expand Down
Loading
Loading