diff --git a/.github/workflows/release-py-core.yml b/.github/workflows/release-py-core.yml new file mode 100644 index 0000000..b3243b9 --- /dev/null +++ b/.github/workflows/release-py-core.yml @@ -0,0 +1,44 @@ +name: Release samp-core + +on: + push: + tags: + - "samp-core/v*" + +permissions: + contents: read + +concurrency: + group: release-samp-core-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build + - run: python -m build + working-directory: python + - uses: actions/upload-artifact@v4 + with: + name: dist + path: python/dist + + publish: + needs: [build] + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/.github/workflows/release-py-crypto.yml b/.github/workflows/release-py-crypto.yml new file mode 100644 index 0000000..329c864 --- /dev/null +++ b/.github/workflows/release-py-crypto.yml @@ -0,0 +1,108 @@ +name: Release samp-crypto + +on: + push: + tags: + - "samp-crypto/v*" + +permissions: + contents: read + +concurrency: + group: release-samp-crypto-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-linux: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [x86_64, aarch64] + manylinux: [manylinux_2_28, musllinux_1_2] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux }} + args: --release --out dist --zig + working-directory: python/samp-crypto + - uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }}-${{ matrix.manylinux }} + path: python/samp-crypto/dist + + build-macos: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + working-directory: python/samp-crypto + - uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }} + path: python/samp-crypto/dist + + build-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + target: [x64] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + working-directory: python/samp-crypto + - uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.target }} + path: python/samp-crypto/dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: python/samp-crypto + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: python/samp-crypto/dist + + publish: + needs: [build-linux, build-macos, build-windows, sdist] + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/python/pyproject.toml b/python/pyproject.toml index f9087d8..f7d749b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "samp-core" -version = "1.2.0" +version = "1.2.1" description = "Substrate Account Messaging Protocol -- Python SDK" license = "MIT" readme = "README.md" requires-python = ">=3.9" -dependencies = ["samp-crypto"] +dependencies = ["samp-crypto==1.0.0"] [project.optional-dependencies] dev = ["pytest", "mypy"] diff --git a/python/samp-crypto/Cargo.toml b/python/samp-crypto/Cargo.toml index 6bf3faf..173967b 100644 --- a/python/samp-crypto/Cargo.toml +++ b/python/samp-crypto/Cargo.toml @@ -9,7 +9,7 @@ name = "samp_crypto" crate-type = ["cdylib"] [dependencies] -pyo3 = "0.28" +pyo3 = { version = "0.28", features = ["abi3-py39"] } schnorrkel = "0.11" curve25519-dalek = { version = "4", features = ["digest"] } hkdf = "0.12" diff --git a/python/samp-crypto/LICENSE b/python/samp-crypto/LICENSE new file mode 100644 index 0000000..2c0fbd0 --- /dev/null +++ b/python/samp-crypto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Maciej Kula + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python/samp-crypto/README.md b/python/samp-crypto/README.md new file mode 100644 index 0000000..d726c18 --- /dev/null +++ b/python/samp-crypto/README.md @@ -0,0 +1,36 @@ +# samp-crypto + +Native crypto backend for [`samp-core`](https://pypi.org/project/samp-core/), the Python SDK for the Substrate Account Messaging Protocol (SAMP). + +This package exposes the SAMP cryptographic primitives — sr25519 key agreement, HKDF-SHA256 key derivation, and ChaCha20-Poly1305 sealing — as a compiled extension module built with [PyO3](https://pyo3.rs) and [maturin](https://www.maturin.rs). It is not meant to be used directly; install `samp-core` instead. + +## Install + +``` +pip install samp-core +``` + +`samp-crypto` is pulled in automatically as a dependency. + +## What it provides + +A single extension module, `samp_crypto`, with stateless functions covering: + +- sr25519 key derivation from a 32-byte seed +- Sealed message encryption to a recipient public key (per the SAMP capsule format) +- Group capsule wrapping and unwrapping +- View-tag derivation for inbox scanning + +The protocol details are specified in the [SAMP specification](https://github.com/samp-org/samp/blob/main/specs/samp.md). + +## Supported platforms + +Wheels are published for: + +- CPython 3.9+ on Linux (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x86_64) + +A source distribution is also published; building from source requires a stable Rust toolchain. + +## License + +MIT. See the repository [LICENSE](https://github.com/samp-org/samp/blob/main/LICENSE). diff --git a/python/samp-crypto/pyproject.toml b/python/samp-crypto/pyproject.toml index 39a5d34..f672866 100644 --- a/python/samp-crypto/pyproject.toml +++ b/python/samp-crypto/pyproject.toml @@ -5,4 +5,34 @@ build-backend = "maturin" [project] name = "samp-crypto" version = "1.0.0" +description = "Native cryptographic backend for samp-core (Substrate Account Messaging Protocol)" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.9" +authors = [{ name = "Maciej Kula" }] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Topic :: Security :: Cryptography", +] + +[project.urls] +Homepage = "https://github.com/samp-org/samp" +Repository = "https://github.com/samp-org/samp" +Issues = "https://github.com/samp-org/samp/issues" +Specification = "https://github.com/samp-org/samp/blob/main/specs/samp.md" + +[tool.maturin] +module-name = "samp_crypto" +features = ["pyo3/extension-module"] diff --git a/python/samp-crypto/src/lib.rs b/python/samp-crypto/src/lib.rs index e79cf54..f7f8ea0 100644 --- a/python/samp-crypto/src/lib.rs +++ b/python/samp-crypto/src/lib.rs @@ -57,46 +57,6 @@ fn sr25519_signing_scalar(seed: &[u8]) -> PyResult> { Ok(secret.to_bytes()[..32].to_vec()) } -#[pyfunction] -fn ecdh(scalar: &[u8], point: &[u8]) -> PyResult> { - if scalar.len() != 32 || point.len() != 32 { - return Err(err("scalar and point must be 32 bytes")); - } - let s = Scalar::from_bytes_mod_order(scalar.try_into().unwrap()); - let p = CompressedRistretto(point.try_into().unwrap()) - .decompress() - .ok_or_else(|| err("invalid ristretto255 point"))?; - Ok((s * p).compress().to_bytes().to_vec()) -} - -#[pyfunction] -fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8], length: usize) -> PyResult> { - let salt_opt = if salt.is_empty() { None } else { Some(salt) }; - let hk = Hkdf::::new(salt_opt, ikm); - let mut out = vec![0u8; length]; - hk.expand(info, &mut out).map_err(|e| err(&e.to_string()))?; - Ok(out) -} - -#[pyfunction] -fn chacha20poly1305_encrypt(key: &[u8], nonce: &[u8], plaintext: &[u8]) -> PyResult> { - if key.len() != 32 || nonce.len() != 12 { - return Err(err("key must be 32 bytes, nonce must be 12 bytes")); - } - let cipher = ChaCha20Poly1305::new(key.into()); - cipher.encrypt(Nonce::from_slice(nonce), plaintext).map_err(|e| err(&e.to_string())) -} - -#[pyfunction] -fn chacha20poly1305_decrypt(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> PyResult> { - if key.len() != 32 || nonce.len() != 12 { - return Err(err("key must be 32 bytes, nonce must be 12 bytes")); - } - let cipher = ChaCha20Poly1305::new(key.into()); - cipher.decrypt(Nonce::from_slice(nonce), ciphertext) - .map_err(|_| err("decryption failed")) -} - fn derive_ephemeral(seed: &[u8; 32], recipient: &[u8; 32], nonce: &[u8; 12]) -> [u8; 32] { let hk = Hkdf::::new(None, seed); let mut info = [0u8; 44]; @@ -519,10 +479,6 @@ fn samp_crypto(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(public_from_seed, m)?)?; m.add_function(wrap_pyfunction!(sr25519_sign, m)?)?; m.add_function(wrap_pyfunction!(sr25519_signing_scalar, m)?)?; - m.add_function(wrap_pyfunction!(ecdh, m)?)?; - m.add_function(wrap_pyfunction!(hkdf_sha256, m)?)?; - m.add_function(wrap_pyfunction!(chacha20poly1305_encrypt, m)?)?; - m.add_function(wrap_pyfunction!(chacha20poly1305_decrypt, m)?)?; m.add_function(wrap_pyfunction!(compute_view_tag, m)?)?; m.add_function(wrap_pyfunction!(encrypt_content, m)?)?; m.add_function(wrap_pyfunction!(decrypt_content, m)?)?; @@ -536,3 +492,106 @@ fn samp_crypto(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(decrypt_from_group, m)?)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn err_msg(e: PyErr) -> String { + pyo3::Python::initialize(); + pyo3::Python::attach(|_py| e.to_string()) + } + + #[test] + fn public_from_seed_rejects_wrong_length() { + let e = public_from_seed(&[0u8; 31]).unwrap_err(); + assert!(err_msg(e).contains("seed must be 32 bytes")); + } + + #[test] + fn sr25519_signing_scalar_rejects_wrong_length() { + let e = sr25519_signing_scalar(&[0u8; 16]).unwrap_err(); + assert!(err_msg(e).contains("seed must be 32 bytes")); + } + + #[test] + fn compute_view_tag_rejects_wrong_lengths() { + let seed = [1u8; 32]; + let pk = [2u8; 32]; + let n = [3u8; 12]; + assert!(compute_view_tag(&seed[..31], &pk, &n).is_err()); + assert!(compute_view_tag(&seed, &pk[..31], &n).is_err()); + assert!(compute_view_tag(&seed, &pk, &n[..11]).is_err()); + } + + #[test] + fn encrypt_content_rejects_wrong_lengths() { + let plaintext = b"hi"; + let recip = [0u8; 32]; + let n = [0u8; 12]; + let seed = [0u8; 32]; + assert!(encrypt_content(plaintext, &recip[..31], &n, &seed).is_err()); + assert!(encrypt_content(plaintext, &recip, &n[..11], &seed).is_err()); + assert!(encrypt_content(plaintext, &recip, &n, &seed[..31]).is_err()); + } + + #[test] + fn encrypt_content_rejects_invalid_ristretto_point() { + let mut bad_pubkey = [0u8; 32]; + bad_pubkey[0] = 0xff; + bad_pubkey[31] = 0xff; + let n = [0u8; 12]; + let seed = [1u8; 32]; + let e = encrypt_content(b"x", &bad_pubkey, &n, &seed).unwrap_err(); + assert!(err_msg(e).contains("invalid recipient pubkey")); + } + + #[test] + fn decrypt_content_rejects_short_content() { + let scalar = [0u8; 32]; + let n = [0u8; 12]; + let e = decrypt_content(&[0u8; ENCRYPTED_OVERHEAD - 1], &scalar, &n).unwrap_err(); + assert!(err_msg(e).contains("content too short")); + } + + #[test] + fn decrypt_content_rejects_wrong_lengths() { + let content = [0u8; ENCRYPTED_OVERHEAD]; + assert!(decrypt_content(&content, &[0u8; 31], &[0u8; 12]).is_err()); + assert!(decrypt_content(&content, &[0u8; 32], &[0u8; 11]).is_err()); + } + + #[test] + fn decrypt_from_group_known_n_overflow() { + let seed = [1u8; 32]; + let recipient_seed = [2u8; 32]; + let recipient_pub = public_from_seed(&recipient_seed).unwrap(); + let nonce = [3u8; 12]; + let (eph, caps, ct) = + encrypt_for_group(b"hi", vec![recipient_pub.clone()], &nonce, &seed).unwrap(); + let mut content = Vec::new(); + content.extend_from_slice(&eph); + content.extend_from_slice(&caps); + content.extend_from_slice(&ct); + let scalar = sr25519_signing_scalar(&recipient_seed).unwrap(); + let e = decrypt_from_group(&content, &scalar, &nonce, Some(usize::MAX)).unwrap_err(); + assert!(err_msg(e).contains("content too short")); + let e2 = decrypt_from_group(&content, &scalar, &nonce, Some(999)).unwrap_err(); + assert!(err_msg(e2).contains("content too short")); + } + + #[test] + fn check_view_tag_matches_compute_view_tag() { + let sender_seed = [7u8; 32]; + let recipient_seed = [9u8; 32]; + let recipient_pub = public_from_seed(&recipient_seed).unwrap(); + let nonce = [11u8; 12]; + let plaintext = b"payload"; + let content = + encrypt_content(plaintext, &recipient_pub, &nonce, &sender_seed).unwrap(); + let sender_tag = compute_view_tag(&sender_seed, &recipient_pub, &nonce).unwrap(); + let recipient_scalar = sr25519_signing_scalar(&recipient_seed).unwrap(); + let recipient_tag = check_view_tag(&recipient_scalar, &content).unwrap(); + assert_eq!(sender_tag, recipient_tag); + } +} diff --git a/python/samp-crypto/tests/test_roundtrip.py b/python/samp-crypto/tests/test_roundtrip.py new file mode 100644 index 0000000..03795ff --- /dev/null +++ b/python/samp-crypto/tests/test_roundtrip.py @@ -0,0 +1,40 @@ +import samp_crypto + + +def test_public_from_seed_returns_32_bytes() -> None: + pub = samp_crypto.public_from_seed(b"\x01" * 32) + assert isinstance(pub, bytes) + assert len(pub) == 32 + + +def test_encrypt_decrypt_roundtrip() -> None: + sender_seed = b"\x01" * 32 + recipient_seed = b"\x02" * 32 + nonce = b"\x03" * 12 + plaintext = b"hello samp" + + recipient_pub = samp_crypto.public_from_seed(recipient_seed) + recipient_scalar = samp_crypto.sr25519_signing_scalar(recipient_seed) + + content = samp_crypto.encrypt_content(plaintext, recipient_pub, nonce, sender_seed) + assert len(content) == 80 + len(plaintext) + + decrypted = samp_crypto.decrypt_content(content, recipient_scalar, nonce) + assert decrypted == plaintext + + decrypted_sender = samp_crypto.decrypt_as_sender(content, sender_seed, nonce) + assert decrypted_sender == plaintext + + +def test_view_tag_agreement() -> None: + sender_seed = b"\x07" * 32 + recipient_seed = b"\x09" * 32 + nonce = b"\x0b" * 12 + + recipient_pub = samp_crypto.public_from_seed(recipient_seed) + recipient_scalar = samp_crypto.sr25519_signing_scalar(recipient_seed) + + content = samp_crypto.encrypt_content(b"x", recipient_pub, nonce, sender_seed) + sender_tag = samp_crypto.compute_view_tag(sender_seed, recipient_pub, nonce) + recipient_tag = samp_crypto.check_view_tag(recipient_scalar, content) + assert sender_tag == recipient_tag