From 4ec732f95dc44af0fe42ff656d0a0725670f1e16 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 16 Jun 2026 15:13:59 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20file=20mode=20=E2=80=94=20create=5Ffile?= =?UTF-8?q?=5Fintent()=20(v1.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt 099 Phase 6 (Python SDK). Adds create_file_intent(document_type, file, *, file_name, file_content_type, valid_from, valid_until, split_key): seals a raw file (PDF/image/DOCX bytes) end-to-end — AES-256-GCM-encrypts the bytes locally (split-key by default), commits over the ciphertext, and registers it with the new file metadata fields so verifiers download the correct type/extension. A verifier decrypts back the exact original bytes. Adds low-level encrypt_bytes()/decrypt_bytes() (encrypt()/decrypt() now delegate). Tests for the binary round-trip + AAD mismatch. Bumped to 1.3.0; CHANGELOG updated. --- CHANGELOG.md | 12 ++++++ pyproject.toml | 2 +- tests/test_crypto.py | 25 ++++++++++++ validpay/__init__.py | 6 ++- validpay/client.py | 91 ++++++++++++++++++++++++++++++++++++++++++++ validpay/crypto.py | 50 +++++++++++++++++------- 6 files changed, 171 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1e1ea..75d886c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e4bbcf7..4594b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 9b9817e..71912af 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -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) diff --git a/validpay/__init__.py b/validpay/__init__.py index cbe04e9..7b637d5 100644 --- a/validpay/__init__.py +++ b/validpay/__init__.py @@ -19,8 +19,10 @@ combine_key_shares, compute_commitment_hash, decrypt, + decrypt_bytes, decrypt_fields, encrypt, + encrypt_bytes, encrypt_fields, generate_key, split_key, @@ -36,7 +38,9 @@ "VerifyIntentResult", "generate_key", "encrypt", + "encrypt_bytes", "decrypt", + "decrypt_bytes", "compute_commitment_hash", "build_aad", "split_key", @@ -51,4 +55,4 @@ "OfflineVerifyResult", ] -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/validpay/client.py b/validpay/client.py index 2f53bf6..d1ed793 100644 --- a/validpay/client.py +++ b/validpay/client.py @@ -17,6 +17,7 @@ decrypt, decrypt_fields, encrypt, + encrypt_bytes, encrypt_fields, generate_key, split_key as split_key_fn, @@ -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]], diff --git a/validpay/crypto.py b/validpay/crypto.py index 4132fe5..5abf785 100644 --- a/validpay/crypto.py +++ b/validpay/crypto.py @@ -104,16 +104,17 @@ 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) @@ -121,18 +122,33 @@ def encrypt(plaintext: str, key: str, aad: str | None = None) -> str: 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) @@ -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(