From 86b1f4c6c0250219c7e41403147f4c67eb860871 Mon Sep 17 00:00:00 2001 From: Mudwood Labs Date: Mon, 31 Aug 2026 23:46:36 -0700 Subject: [PATCH] feat(crypto): export the content-encryption primitives (#556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #556 found the timelocked-content feature unreachable — ~1,100 lines and five test files with no CLI command, no client method and no export. The crypto half is resolved here; the timelock half is deliberately NOT. WHY THEY SPLIT. `crypto/aead` and `crypto/kem` are general primitives, and their reachability does not depend on what happens to the feature that currently consumes them. They are also BYTE-COMPATIBLE WITH PHOTONIC WALLET (`packages/lib/src/encryption.ts`), verified against the draft-irtf-cfrg-xchacha-03 Appendix A.3.1 vector — encrypting content with pyrxd and decrypting it in Photonic is the actual use case, which is what makes them worth a public surface rather than an internal detail. THE EXPORTED SET IS CHOSEN TO BE USABLE, not just to clear the allowlist. Same rule the file already applies to GlyphMinter/JsonFilePendingStore: `encrypt_ chunked` returns a `ChunkedCiphertext` that `decrypt_chunked` consumes, and `wrap_cek_x25519` needs a recipient public key a caller can only derive with `x25519_public_key`. Exporting the four flagged functions alone would have cleared the allowlist and left the entry point unreachable in practice — which is the defect class the export exists to answer, reintroduced by the fix for it. `test_the_exported_crypto_surface_is_USABLE_end_to_end_from_the_top_level` drives a full wrap -> encrypt -> unwrap -> decrypt round trip using nothing but names off the top-level package. Verified by planting: removing the `x25519_public_key` export fails it, while every per-name `hasattr` check still passes — the difference between a symbol being importable and a feature being reachable. It also pins that `decrypt_chunked` takes `plaintext_hash` SEPARATELY from the ciphertext object that carries one. That reads as an awkward signature and is the design: the hash is the on-chain commitment, so sourcing it from the ciphertext would let whoever supplied the ciphertext also supply the value it is authenticated against. A wrong commitment must fail, and now does in a test. The four crypto allowlist entries are removed; the stale-entry ratchet was verified to fire by re-adding one. The seven timelock entries keep their place but no longer say "needs triage" — they record the decision: KEPT UNWIRED, and what remains is a product call (a CLI/GlyphClient entry point, or deleting ~700 lines and three test files), not a code one. Lazy-loading preserved: a new test pins that `import pyrxd` still does not pull the Cryptodome cipher backend. VERIFIED as CI runs it (`-o "addopts="`, so nothing is deselected): pytest tests/ --cov=pyrxd --cov-fail-under=85 -> 10,515 passed, 90.42% pytest tests/security/ --cov-fail-under=100 -> 866 passed, 100% ruff check + format --check, mypy -> clean Co-Authored-By: Claude Opus 5 (1M context) --- src/pyrxd/__init__.py | 25 +++++++ tests/test_reachability_shipped_callers.py | 18 ++--- tests/test_sdk_exports.py | 77 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/pyrxd/__init__.py b/src/pyrxd/__init__.py index 1bce0058..f323d2ab 100644 --- a/src/pyrxd/__init__.py +++ b/src/pyrxd/__init__.py @@ -152,6 +152,31 @@ "SpvProof": ("pyrxd.spv", "SpvProof"), "SpvProofBuilder": ("pyrxd.spv", "SpvProofBuilder"), "verify_tx_in_block": ("pyrxd.spv", "verify_tx_in_block"), + # Content encryption primitives — pyrxd.crypto (#556). + # + # These are BYTE-COMPATIBLE WITH PHOTONIC WALLET (`packages/lib/src/encryption.ts`), which is + # what makes them worth a public export rather than an internal detail: a caller encrypting + # Glyph content with pyrxd and decrypting it in Photonic, or the reverse, is the actual use + # case. `aead` is verified against the draft-irtf-cfrg-xchacha-03 Appendix A.3.1 vector. + # + # Exported rather than wired to a caller, deliberately. #556 found them unreachable along with + # the timelocked-content feature that is their only in-repo consumer, but they are GENERAL + # primitives whose reachability does not depend on that feature's fate — see the allowlist in + # tests/test_reachability_shipped_callers.py, where the timelock half stays recorded as debt. + # + # The set is chosen so the surface is USABLE, the same rule as GlyphMinter/JsonFilePendingStore + # above: `encrypt_chunked` returns a `ChunkedCiphertext` that `decrypt_chunked` consumes, and + # `wrap_cek_x25519` needs a recipient public key the caller can only derive with + # `x25519_public_key`. Exporting the functions without those leaves the entry point + # unreachable in practice, which is the defect class this whole export exists to answer. + "ChunkedCiphertext": ("pyrxd.crypto.aead", "ChunkedCiphertext"), + "EncryptedChunk": ("pyrxd.crypto.aead", "EncryptedChunk"), + "WrappedCEK": ("pyrxd.crypto.kem", "WrappedCEK"), + "decrypt_chunked": ("pyrxd.crypto.aead", "decrypt_chunked"), + "encrypt_chunked": ("pyrxd.crypto.aead", "encrypt_chunked"), + "unwrap_cek_x25519": ("pyrxd.crypto.kem", "unwrap_cek_x25519"), + "wrap_cek_x25519": ("pyrxd.crypto.kem", "wrap_cek_x25519"), + "x25519_public_key": ("pyrxd.crypto.kem", "x25519_public_key"), # Local regtest dev node (see `pyrxd regtest` / the quickstart tutorial) "RegtestNode": ("pyrxd.devnet", "RegtestNode"), } diff --git a/tests/test_reachability_shipped_callers.py b/tests/test_reachability_shipped_callers.py index 2a6a3e7a..8625cf00 100644 --- a/tests/test_reachability_shipped_callers.py +++ b/tests/test_reachability_shipped_callers.py @@ -84,19 +84,15 @@ # public API and want a deliberate `__init__` export instead of a caller. "src/pyrxd/btc_wallet/chains.py::pow_chain_by_network": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", "src/pyrxd/btc_wallet/taproot.py::nums_point_is_unspendable": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/crypto/aead.py::decrypt_chunked": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/crypto/aead.py::encrypt_chunked": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/crypto/kem.py::unwrap_cek_x25519": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/crypto/kem.py::wrap_cek_x25519": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", "src/pyrxd/eth_wallet/keys.py::generate_eth_key": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", "src/pyrxd/glyph/credential_binding.py::verify_credential_binding": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock.py::add_timelock_to_metadata": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock.py::get_unlock_remaining": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock.py::is_unlocked": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock.py::verify_cek_reveal": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock_reveal_tx.py::create_reveal_proof": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock_reveal_tx.py::parse_reveal_proof_script": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", - "src/pyrxd/glyph/timelock_reveal_tx.py::validate_reveal_proof": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", + "src/pyrxd/glyph/timelock.py::add_timelock_to_metadata": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", + "src/pyrxd/glyph/timelock.py::get_unlock_remaining": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", + "src/pyrxd/glyph/timelock.py::is_unlocked": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", + "src/pyrxd/glyph/timelock.py::verify_cek_reveal": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", + "src/pyrxd/glyph/timelock_reveal_tx.py::create_reveal_proof": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", + "src/pyrxd/glyph/timelock_reveal_tx.py::parse_reveal_proof_script": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", + "src/pyrxd/glyph/timelock_reveal_tx.py::validate_reveal_proof": "#556 TRIAGED 2026-09-01: timelocked-content feature, KEPT UNWIRED on purpose. Its crypto primitives were exported instead; this half needs a PRODUCT decision (CLI/GlyphClient entry point, or delete ~700 lines + 3 test files), not a code one", "src/pyrxd/gravity/swap_state.py::allowed_targets": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", "src/pyrxd/gravity/swap_state.py::can_transition": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", "src/pyrxd/gravity/watch/claim_executor.py::sidecar_leg_resolver": "EXPOSED 2026-08-31 by closing the __all__ hole; PRE-EXISTING, needs triage", diff --git a/tests/test_sdk_exports.py b/tests/test_sdk_exports.py index a9795454..9c940892 100644 --- a/tests/test_sdk_exports.py +++ b/tests/test_sdk_exports.py @@ -57,3 +57,80 @@ def test_importing_pyrxd_does_not_eagerly_load_web3(): code = "import sys; import pyrxd; sys.exit(1 if 'web3' in sys.modules else 0)" result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) assert result.returncode == 0, f"importing pyrxd eagerly loaded web3 (should be lazy)\n{result.stderr}" + + +# --------------------------------------------------------------- content encryption (#556) -- + +_CRYPTO_EXPORTS = [ + "ChunkedCiphertext", + "EncryptedChunk", + "WrappedCEK", + "decrypt_chunked", + "encrypt_chunked", + "unwrap_cek_x25519", + "wrap_cek_x25519", + "x25519_public_key", +] + + +@pytest.mark.parametrize("name", _CRYPTO_EXPORTS) +def test_crypto_primitive_is_importable_from_top_level(name): + import pyrxd + + assert name in pyrxd.__all__, f"{name} missing from pyrxd.__all__" + assert getattr(pyrxd, name) is not None + + +def test_the_exported_crypto_surface_is_USABLE_end_to_end_from_the_top_level(): + """The reachability point of #556, and the reason this is not just a `hasattr` sweep. + + The four flagged symbols were unreachable: no CLI, no client method, no export — reached only + by tests importing `pyrxd.crypto.*` directly. Exporting them is the fix, and an export is only + a fix if the exported set is SUFFICIENT to do the job. So this drives a full round trip using + NOTHING but names off the top-level package: wrap a CEK to a recipient, encrypt with it, then + unwrap and decrypt back. + + A caller cannot produce a recipient public key without `x25519_public_key`, which is why it is + exported alongside the wrap/unwrap pair. Drop it and this test fails at the second line while + the per-name checks above all still pass — which is exactly the difference between a symbol + being importable and a feature being reachable. + """ + import os + + import pyrxd + + recipient_priv = os.urandom(32) + recipient_pub = pyrxd.x25519_public_key(recipient_priv) + + cek = os.urandom(32) + wrapped = pyrxd.wrap_cek_x25519(cek, recipient_pub) + assert isinstance(wrapped, pyrxd.WrappedCEK) + + plaintext = os.urandom(100_000) # > CHUNK_SIZE, so the chunked path really chunks + sealed = pyrxd.encrypt_chunked(plaintext, cek) + assert isinstance(sealed, pyrxd.ChunkedCiphertext) + assert len(sealed.chunks) > 1, "fixture too small to exercise the chunked path it is named for" + assert all(isinstance(c, pyrxd.EncryptedChunk) for c in sealed.chunks) + + recovered_cek = pyrxd.unwrap_cek_x25519(wrapped.wrapped_cek, wrapped.ephemeral_pubkey, recipient_priv) + assert recovered_cek == cek + + # `plaintext_hash` is passed SEPARATELY even though `sealed` carries one, and that is the + # design rather than an awkward signature: the hash is the on-chain commitment, so taking it + # from the ciphertext object would let whoever supplied the ciphertext also supply the value it + # is authenticated against. Passing `sealed.plaintext_hash` here is only safe because this test + # produced both; a real caller reads it from the Glyph metadata. + assert pyrxd.decrypt_chunked(sealed, recovered_cek, sealed.plaintext_hash) == plaintext + + # ...and a WRONG commitment must fail rather than decrypt, which is what makes the separate + # argument load-bearing instead of ceremonial. + with pytest.raises(ValueError): + pyrxd.decrypt_chunked(sealed, recovered_cek, b"\x00" * 32) + + +def test_importing_pyrxd_does_not_eagerly_load_the_cipher_backend(): + """The crypto exports must stay lazy like everything else. `Cryptodome` is a real import cost + and only content encryption needs it; a non-lazy export would put it in every `import pyrxd`.""" + code = "import sys; import pyrxd; sys.exit(1 if 'Cryptodome.Cipher.ChaCha20_Poly1305' in sys.modules else 0)" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, f"importing pyrxd eagerly loaded the cipher backend\n{result.stderr}"