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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ 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.3.0] - 2026-06-16

### Added

- **File mode — `create_file_intent()`** (Prompt 099). Seal a full document
file (PDF, image, DOCX, …) end-to-end: pass the raw `bytes`, an optional
`file_name` and `file_content_type`, and the SDK AES-256-GCM-encrypts the
bytes locally (split-key by default) and registers them. A verifier decrypts
back the exact original bytes for a byte-for-byte match.
- Low-level `encrypt_bytes()` / `decrypt_bytes()` helpers for raw-bytes
payloads (the existing `encrypt()` / `decrypt()` now delegate to them).

## [1.1.0] - 2026-06-12

### Changed
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.2.0"
version = "1.3.0"
description = "Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
25 changes: 25 additions & 0 deletions tests/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,39 @@
combine_key_shares,
compute_commitment_hash,
decrypt,
decrypt_bytes,
decrypt_fields,
encrypt,
encrypt_bytes,
encrypt_fields,
generate_key,
split_key,
)


def test_encrypt_bytes_roundtrip_binary():
# Non-UTF-8 bytes (a tiny "PDF-ish" header + binary noise) must survive
# encrypt_bytes -> decrypt_bytes exactly, byte-for-byte (file mode).
key = generate_key()
original = b"%PDF-1.7\x00\x01\x02\xff\xfe\r\n binary \x80\x90"
blob = encrypt_bytes(original, key)
assert decrypt_bytes(blob, key) == original


def test_encrypt_bytes_aad_mismatch_fails():
key = generate_key()
blob = encrypt_bytes(b"\x00\x01\x02", key, aad="a")
with pytest.raises(ValidPayError):
decrypt_bytes(blob, key, aad="b")


def test_encrypt_delegates_to_encrypt_bytes():
# The string wrapper must produce a blob decrypt_bytes reads back as UTF-8.
key = generate_key()
blob = encrypt("héllo ✓", key)
assert decrypt_bytes(blob, key).decode("utf-8") == "héllo ✓"


def test_generate_key_returns_32_byte_base64():
key = generate_key()
assert isinstance(key, str)
Expand Down
6 changes: 5 additions & 1 deletion validpay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
combine_key_shares,
compute_commitment_hash,
decrypt,
decrypt_bytes,
decrypt_fields,
encrypt,
encrypt_bytes,
encrypt_fields,
generate_key,
split_key,
Expand All @@ -36,7 +38,9 @@
"VerifyIntentResult",
"generate_key",
"encrypt",
"encrypt_bytes",
"decrypt",
"decrypt_bytes",
"compute_commitment_hash",
"build_aad",
"split_key",
Expand All @@ -51,4 +55,4 @@
"OfflineVerifyResult",
]

__version__ = "1.2.0"
__version__ = "1.3.0"
91 changes: 91 additions & 0 deletions validpay/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
decrypt,
decrypt_fields,
encrypt,
encrypt_bytes,
encrypt_fields,
generate_key,
split_key as split_key_fn,
Expand Down Expand Up @@ -174,6 +175,96 @@ def create_intent(

return CreateIntentResult(retrieval_id=retrieval_id, key=result_key)

def create_file_intent(
self,
document_type: str,
file: bytes,
*,
file_name: Optional[str] = None,
file_content_type: Optional[str] = None,
valid_from: Optional[str] = None,
valid_until: Optional[str] = None,
split_key: bool = True,
) -> CreateIntentResult:
"""Seal a full document file (PDF, image, DOCX, …) end-to-end.

Unlike :meth:`create_intent`, which JSON-encodes a structured payload,
this encrypts the raw ``file`` bytes directly with AES-256-GCM and
registers them — so a verifier decrypts back the exact original bytes
and can confirm a byte-for-byte match. Split-key protection (Patent C)
is on by default, identical to :meth:`create_intent`.

Args:
document_type: Short document-kind string (``"contract"``,
``"wire_instructions"``, ``"title"``, …).
file: Raw file bytes to seal. Encrypted locally; never sent in the
clear.
file_name: Original filename, stored for the issuer's records. It
is treated as potentially sensitive and is NOT echoed on the
public verify endpoint.
file_content_type: MIME type (``"application/pdf"``,
``"image/png"``, …). Returned on verify so downloads get the
right type/extension instead of a generic ``.bin``.
valid_from: Optional ISO-8601 start of the validity window.
valid_until: Optional ISO-8601 end of the validity window.
split_key: Default ``True`` (Patent C). ``False`` returns the full
AES key in the result instead of Share A.

Returns:
A :class:`CreateIntentResult` with the retrieval id and the key
material (Share A by default).
"""
if not document_type:
raise ValidPayError("invalid_argument", "document_type is required")
if not isinstance(file, (bytes, bytearray)):
raise ValidPayError("invalid_argument", "file must be bytes")
if len(file) == 0:
raise ValidPayError("invalid_argument", "file is empty")
_validate_time_lock(valid_from, valid_until)

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)

# M-5: bind document_type + validity window as AAD, same as create_intent.
aad = build_aad(document_type, valid_from, valid_until)
encrypted_payload = encrypt_bytes(bytes(file), full_key, aad)
# Commitment v2: hash the ciphertext, not the plaintext (C-1).
commitment_hash = compute_commitment_hash(encrypted_payload)

body: Dict[str, Any] = {
"document_type": document_type,
"encrypted_payload": encrypted_payload,
"commitment_hash": commitment_hash,
"encryption_version": 2,
"file_size_bytes": len(file),
}
if split_key:
body["split_key"] = True
body["key_fragment_b"] = share_b
if file_name is not None:
body["file_name"] = file_name
if file_content_type is not None:
body["file_content_type"] = file_content_type
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)

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=result_key)

def create_intent_batch(
self,
intents: Iterable[Mapping[str, Any]],
Expand Down
50 changes: 37 additions & 13 deletions validpay/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,35 +104,51 @@ def _decode_key(key: str) -> bytes:
return buf


def encrypt(plaintext: str, key: str, aad: str | None = None) -> str:
"""Encrypt ``plaintext`` (UTF-8) with the given base64 AES-256 key.
def encrypt_bytes(plaintext: bytes, key: str, aad: str | None = None) -> str:
"""Encrypt raw ``plaintext`` bytes with the given base64 AES-256 key.

Returns a base64 string in the ValidPay wire format::

base64(iv[12] || authTag[16] || ciphertext)

``aad`` (Prompt 097 M-5) is optional Associated Authenticated Data — when
supplied, the same string MUST be passed to :func:`decrypt` or the GCM tag
check fails. Use :func:`build_aad` to bind document metadata.
Use this for file mode (PDF/image/DOCX bytes); :func:`encrypt` is the
UTF-8 string convenience wrapper for structured payloads. ``aad`` (Prompt
097 M-5) is optional Associated Authenticated Data — when supplied, the
same string MUST be passed to :func:`decrypt` or the GCM tag check fails.
"""
key_bytes = _decode_key(key)
iv = os.urandom(_IV_BYTES)
aesgcm = AESGCM(key_bytes)
aad_bytes = aad.encode("utf-8") if aad else None
# cryptography's AESGCM.encrypt returns ``ciphertext || authTag``.
# Rearrange to match the ValidPay/Node wire format: iv || authTag || ciphertext.
ct_with_tag = aesgcm.encrypt(iv, plaintext.encode("utf-8"), aad_bytes)
ct_with_tag = aesgcm.encrypt(iv, plaintext, aad_bytes)
auth_tag = ct_with_tag[-_TAG_BYTES:]
ciphertext = ct_with_tag[:-_TAG_BYTES]
return base64.b64encode(iv + auth_tag + ciphertext).decode("ascii")


def decrypt(blob: str, key: str, aad: str | None = None) -> str:
"""Decrypt a ValidPay-format base64 blob and return the plaintext (UTF-8).
def encrypt(plaintext: str, key: str, aad: str | None = None) -> str:
"""Encrypt ``plaintext`` (UTF-8) with the given base64 AES-256 key.

``aad`` must match the value passed to :func:`encrypt` (Prompt 097 M-5);
a mismatch (e.g. a server that altered the bound metadata) raises
``decryption_failed``.
Returns a base64 string in the ValidPay wire format::

base64(iv[12] || authTag[16] || ciphertext)

``aad`` (Prompt 097 M-5) is optional Associated Authenticated Data — when
supplied, the same string MUST be passed to :func:`decrypt` or the GCM tag
check fails. Use :func:`build_aad` to bind document metadata.
"""
return encrypt_bytes(plaintext.encode("utf-8"), key, aad)


def decrypt_bytes(blob: str, key: str, aad: str | None = None) -> bytes:
"""Decrypt a ValidPay-format base64 blob and return the raw plaintext bytes.

Use this for file mode (the original is binary); :func:`decrypt` is the
UTF-8 string wrapper. ``aad`` must match the value passed to
:func:`encrypt_bytes` / :func:`encrypt` (Prompt 097 M-5); a mismatch (e.g.
a server that altered the bound metadata) raises ``decryption_failed``.
"""
key_bytes = _decode_key(key)

Expand All @@ -154,14 +170,22 @@ def decrypt(blob: str, key: str, aad: str | None = None) -> str:
aesgcm = AESGCM(key_bytes)
aad_bytes = aad.encode("utf-8") if aad else None
try:
plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag, aad_bytes)
return aesgcm.decrypt(iv, ciphertext + auth_tag, aad_bytes)
except InvalidTag as exc:
raise ValidPayError(
"decryption_failed",
"Decryption failed — wrong key, tampered blob, or altered bound metadata",
) from exc

return plaintext.decode("utf-8")

def decrypt(blob: str, key: str, aad: str | None = None) -> str:
"""Decrypt a ValidPay-format base64 blob and return the plaintext (UTF-8).

``aad`` must match the value passed to :func:`encrypt` (Prompt 097 M-5);
a mismatch (e.g. a server that altered the bound metadata) raises
``decryption_failed``.
"""
return decrypt_bytes(blob, key, aad).decode("utf-8")


def build_aad(
Expand Down
Loading