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
25 changes: 25 additions & 0 deletions src/pyrxd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
Expand Down
18 changes: 7 additions & 11 deletions tests/test_reachability_shipped_callers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions tests/test_sdk_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Loading