diff --git a/.github/workflows/fuzz-extended.yml b/.github/workflows/fuzz-extended.yml index 0c874695..f03c4265 100644 --- a/.github/workflows/fuzz-extended.yml +++ b/.github/workflows/fuzz-extended.yml @@ -15,7 +15,7 @@ on: jobs: extended_fuzz: runs-on: ubuntu-22.04 - timeout-minutes: 60 # 1 hour max + timeout-minutes: 180 # 3 hours max steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/python/build/linux/action.yml b/.github/workflows/python/build/linux/action.yml index f0db43d7..4b64a4f3 100644 --- a/.github/workflows/python/build/linux/action.yml +++ b/.github/workflows/python/build/linux/action.yml @@ -4,7 +4,7 @@ runs: steps: - uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.14" - name: Installing dependencies shell: bash @@ -31,6 +31,18 @@ runs: working-directory: "./python" shell: bash + - name: Building 3.14 wheel + working-directory: ./python + shell: bash + run: | + maturin build -i 3.14 --release --target=x86_64-unknown-linux-gnu + maturin build -i 3.14 --release --target=i686-unknown-linux-gnu + maturin build -i 3.14 --release --target=aarch64-unknown-linux-gnu + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Building 3.13 wheel working-directory: ./python shell: bash @@ -75,18 +87,6 @@ runs: maturin build -i 3.10 --release --target=i686-unknown-linux-gnu maturin build -i 3.10 --release --target=aarch64-unknown-linux-gnu - - uses: actions/setup-python@v5 - with: - python-version: "3.9" - - - name: Building 3.9 Wheel - working-directory: ./python - shell: bash - run: | - maturin build -i 3.9 --release --target=x86_64-unknown-linux-gnu - maturin build -i 3.9 --release --target=i686-unknown-linux-gnu - maturin build -i 3.9 --release --target=aarch64-unknown-linux-gnu - - name: Place Artifacts shell: bash run: | diff --git a/.github/workflows/python/build/macos/action.yml b/.github/workflows/python/build/macos/action.yml index 4d3d1ff2..821c3ced 100644 --- a/.github/workflows/python/build/macos/action.yml +++ b/.github/workflows/python/build/macos/action.yml @@ -4,7 +4,7 @@ runs: steps: - uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.14" - name: Install rust shell: bash @@ -22,6 +22,19 @@ runs: shell: bash run: pip install maturin --disable-pip-version-check + - name: Building 3.14 Wheel + working-directory: ./python + shell: bash + run: | + source $HOME/.cargo/env + + maturin build --release -i 3.14 --target=x86_64-apple-darwin + maturin build --release -i 3.14 --target=aarch64-apple-darwin + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Building 3.13 Wheel working-directory: ./python shell: bash @@ -70,19 +83,6 @@ runs: maturin build --release -i 3.10 --target=x86_64-apple-darwin maturin build --release -i 3.10 --target=aarch64-apple-darwin - - uses: actions/setup-python@v5 - with: - python-version: "3.9" - - - name: Building 3.9 Wheel - working-directory: ./python - shell: bash - run: | - source $HOME/.cargo/env - - maturin build --release -i 3.9 --target=x86_64-apple-darwin - maturin build --release -i 3.9 --target=aarch64-apple-darwin - - name: Place Artifacts shell: bash run: | diff --git a/.github/workflows/python/build/windows/action.yml b/.github/workflows/python/build/windows/action.yml index 06e20dfa..5abfcb3b 100644 --- a/.github/workflows/python/build/windows/action.yml +++ b/.github/workflows/python/build/windows/action.yml @@ -4,7 +4,7 @@ runs: steps: - uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.14" - name: Setting up PATH environment variable shell: bash @@ -22,6 +22,28 @@ runs: shell: bash run: pip install maturin + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + architecture: "x86" + + - name: Building i686 3.14 wheel + working-directory: ./python + shell: bash + run: maturin build -i 3.14 --release --target=i686-pc-windows-msvc + + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + architecture: "x64" + + - name: Building x86_64, aarch64 3.14 wheel + working-directory: ./python + shell: bash + run: | + maturin build -i 3.14 --release --target=x86_64-pc-windows-msvc + maturin build -i 3.14 --release --target=aarch64-pc-windows-msvc + - uses: actions/setup-python@v5 with: python-version: "3.13" @@ -110,28 +132,6 @@ runs: maturin build -i 3.10 --release --target=x86_64-pc-windows-msvc maturin build -i 3.10 --release --target=aarch64-pc-windows-msvc - - uses: actions/setup-python@v5 - with: - python-version: "3.9" - architecture: "x86" - - - name: Building i686 3.9 wheel - working-directory: ./python - shell: bash - run: maturin build -i 3.9 --release --target=i686-pc-windows-msvc - - - uses: actions/setup-python@v5 - with: - python-version: "3.9" - architecture: "x64" - - - name: Building x86_64, aarch64 3.9 wheel - working-directory: ./python - shell: bash - run: | - maturin build -i 3.9 --release --target=x86_64-pc-windows-msvc - maturin build -i 3.9 --release --target=aarch64-pc-windows-msvc - - name: Place Artifacts shell: bash run: mv target/wheels/*.whl . diff --git a/.github/workflows/tests/csharp/ios/action.yml b/.github/workflows/tests/csharp/ios/action.yml index 3987fa6a..c93626ee 100644 --- a/.github/workflows/tests/csharp/ios/action.yml +++ b/.github/workflows/tests/csharp/ios/action.yml @@ -26,18 +26,18 @@ runs: - name: Extract UDID shell: bash - run: | - # Find the UDID of the iPhone 16 simulator running iOS 18 - SIMULATOR_UDID=$(xcrun simctl list devices available 'iOS 18' | grep 'iPhone 16' | awk -F '[()]' '{print $2}' | head -n 1) + run: | + # Find the UDID of the iPhone 17 simulator running iOS 26 + SIMULATOR_UDID=$(xcrun simctl list devices available | grep -E 'iOS 26' | grep 'iPhone 17 ' | awk -F '[()]' '{print $2}' | head -n 1) # Check if a UDID was found if [ -n "$SIMULATOR_UDID" ]; then # Export it as an environment variable export IPHONE_16_SIM_UDID=$SIMULATOR_UDID echo "IPHONE_16_SIM_UDID=$SIMULATOR_UDID" >> $GITHUB_ENV - echo "iPhone 16 UDID stored in environment variable IPHONE_16_SIM_UDID: $IPHONE_16_SIM_UDID" + echo "iPhone 17 iOS 26 UDID stored in environment variable IPHONE_16_SIM_UDID: $IPHONE_16_SIM_UDID" else - echo "iPhone 16 simulator with iOS 18 not found." + echo "iPhone 17 simulator with iOS 26 not found." exit 1 fi diff --git a/Cargo.lock b/Cargo.lock index 9ffe4c44..71da5935 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -992,11 +992,10 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.24.2" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" dependencies = [ - "cfg-if", "indoc", "libc", "memoffset", @@ -1010,20 +1009,19 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.24.2" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" dependencies = [ - "once_cell", "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.24.2" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" dependencies = [ "libc", "pyo3-build-config", @@ -1031,9 +1029,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.24.2" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -1043,9 +1041,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.24.2" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" dependencies = [ "heck", "proc-macro2", diff --git a/python/Cargo.toml b/python/Cargo.toml index a9e355b6..78872874 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -13,4 +13,4 @@ crate-type = ["cdylib"] devolutions-crypto = { path = "../" } zeroize = "1" base64 = "0.22" -pyo3 = { version = "0.24", features = ["extension-module", "generate-import-lib"] } +pyo3 = { version = "0.27", features = ["extension-module", "generate-import-lib"] } diff --git a/python/PYPI_README.md b/python/PYPI_README.md new file mode 100644 index 00000000..04cf55fb --- /dev/null +++ b/python/PYPI_README.md @@ -0,0 +1,303 @@ +# devolutions-crypto + +[![PyPI version](https://img.shields.io/pypi/v/devolutions-crypto.svg)](https://pypi.org/project/devolutions-crypto/) +[![Python versions](https://img.shields.io/pypi/pyversions/devolutions-crypto.svg)](https://pypi.org/project/devolutions-crypto/) + +Cryptographic library used in Devolutions products. It is made to be fast, easy to use and misuse-resistant. + +This is the official Python wrapper for the [devolutions-crypto](https://github.com/devolutions/devolutions-crypto) Rust library, providing high-performance cryptographic operations with a simple, Pythonic API. + +## Installation + +```bash +pip install devolutions-crypto +``` + +## Features + +- **Symmetric Encryption**: Fast AES-256-GCM encryption for shared-key scenarios +- **Asymmetric Encryption**: X25519-based public-key encryption +- **Password Hashing**: Secure password hashing with PBKDF2 +- **Digital Signatures**: Ed25519 signatures for data authentication +- **Key Derivation**: PBKDF2 and Argon2 key derivation functions +- **Type Safety**: Full type hints and IDE support via stub files + +## Quick Start + +```python +import devolutions_crypto +import os + +# Generate a random encryption key +key = os.urandom(32) + +# Encrypt some data +plaintext = b"Hello, World!" +ciphertext = devolutions_crypto.encrypt(plaintext, key) + +# Decrypt it back +decrypted = devolutions_crypto.decrypt(ciphertext, key) +assert decrypted == plaintext +``` + +## Usage Examples + +### Table of Contents + +* [Symmetric Encryption](#symmetric-encryption) +* [Asymmetric Encryption](#asymmetric-encryption) +* [Password Hashing](#password-hashing) +* [Digital Signatures](#digital-signatures) +* [Key Derivation](#key-derivation) + +### Symmetric Encryption + +Use symmetric encryption when both parties share the same secret key. + +```python +import devolutions_crypto +import os + +# Generate a 32-byte encryption key +key = os.urandom(32) + +# Encrypt data +plaintext = b"This is secret data" +ciphertext = devolutions_crypto.encrypt(plaintext, key) + +# Decrypt data +decrypted = devolutions_crypto.decrypt(ciphertext, key) +assert decrypted == plaintext +``` + +#### With Additional Authenticated Data (AAD) + +AAD allows you to bind additional context to the ciphertext without encrypting it: + +```python +import devolutions_crypto +import os + +key = os.urandom(32) +plaintext = b"Secret message" +aad = b"user_id:12345" # Context data (not encrypted, but authenticated) + +# Encrypt with AAD +ciphertext = devolutions_crypto.encrypt(plaintext, key, aad=aad) + +# Decrypt with AAD (must match encryption AAD) +decrypted = devolutions_crypto.decrypt(ciphertext, key, aad=aad) +assert decrypted == plaintext + +# Decryption fails with wrong or missing AAD +try: + devolutions_crypto.decrypt(ciphertext, key, aad=b"wrong_context") +except devolutions_crypto.DevolutionsCryptoException: + print("Authentication failed - AAD mismatch") +``` + +### Asymmetric Encryption + +Use asymmetric encryption when you want to encrypt data for a recipient using their public key. + +```python +import devolutions_crypto + +# Generate a keypair +keypair = devolutions_crypto.generate_keypair() + +# Encrypt data with the public key +plaintext = b"Secret message for Bob" +ciphertext = devolutions_crypto.encrypt_asymmetric(plaintext, keypair.public_key) + +# Decrypt with the private key +decrypted = devolutions_crypto.decrypt_asymmetric(ciphertext, keypair.private_key) +assert decrypted == plaintext +``` + +#### Key Exchange Example + +Alice and Bob can establish a shared secret without transmitting it: + +```python +import devolutions_crypto + +# Alice generates her keypair +alice_keypair = devolutions_crypto.generate_keypair() + +# Bob generates his keypair +bob_keypair = devolutions_crypto.generate_keypair() + +# They exchange public keys (public keys can be transmitted over insecure channels) + +# Alice encrypts a message for Bob using his public key +message = b"Hello Bob!" +ciphertext = devolutions_crypto.encrypt_asymmetric(message, bob_keypair.public_key) + +# Bob decrypts the message using his private key +decrypted = devolutions_crypto.decrypt_asymmetric(ciphertext, bob_keypair.private_key) +assert decrypted == message +``` + +### Password Hashing + +Securely hash and verify passwords using PBKDF2: + +```python +import devolutions_crypto + +# Hash a password (this is slow by design - use high iterations) +password = b"my_secure_password123!" +password_hash = devolutions_crypto.hash_password( + password, + iterations=100000, # Higher is more secure but slower + version=0 +) + +# Verify the password +is_valid = devolutions_crypto.verify_password(password, password_hash) +assert is_valid is True + +# Wrong password fails verification +is_valid = devolutions_crypto.verify_password(b"wrong_password", password_hash) +assert is_valid is False +``` + +### Digital Signatures + +Sign data to prove authenticity and verify signatures: + +#### Generating a Signing Keypair + +```python +import devolutions_crypto + +# Generate a signing keypair +signing_keypair = devolutions_crypto.generate_signing_keypair() + +# Extract the public key +public_key = devolutions_crypto.get_signing_public_key(signing_keypair) +``` + +#### Signing Data + +```python +import devolutions_crypto + +# Sign some data +data = b"This is an important message" +signature = devolutions_crypto.sign(data, signing_keypair) +``` + +#### Verifying Signatures + +```python +import devolutions_crypto + +# Verify the signature with the public key +is_valid = devolutions_crypto.verify_signature(data, public_key, signature) +assert is_valid is True + +# Verification fails for modified data +modified_data = b"This is a tampered message" +is_valid = devolutions_crypto.verify_signature(modified_data, public_key, signature) +assert is_valid is False +``` + +### Key Derivation + +Derive cryptographic keys from passwords or other key material. + +#### PBKDF2 + +```python +import devolutions_crypto +import os + +# Derive a key from a password +password = b"user_password" +salt = os.urandom(16) # Use a unique random salt per user + +derived_key = devolutions_crypto.derive_key_pbkdf2( + password, + salt=salt, + iterations=100000, + length=32 +) + +# Use the derived key for encryption +plaintext = b"User data" +ciphertext = devolutions_crypto.encrypt(plaintext, derived_key) +``` + +#### Argon2 + +```python +import devolutions_crypto + +# Derive a key using Argon2 (requires Argon2Parameters) +password = b"user_password" +# parameters should be serialized Argon2Parameters +derived_key = devolutions_crypto.derive_key_argon2(password, parameters) +``` + +## Supported Python Versions + +- Python 3.10+ +- Python 3.11 +- Python 3.12 +- Python 3.13 +- Python 3.14 + +## Supported Platforms + +Pre-built wheels are available for: +- **Linux**: x86_64, i686, aarch64 +- **macOS**: x86_64 (Intel), aarch64 (Apple Silicon) +- **Windows**: x86, x64, ARM64 + +## Security Notes + +1. **Key Management**: Always use cryptographically secure random number generators (like `os.urandom()`) for key generation +2. **Salt Uniqueness**: Use unique salts for each password/user when deriving keys +3. **Iterations**: Use high iteration counts (100,000+) for password hashing and key derivation +4. **Key Size**: Use 32-byte (256-bit) keys for symmetric encryption +5. **AAD**: Additional Authenticated Data must match exactly between encryption and decryption + +## Exception Handling + +All functions may raise `DevolutionsCryptoException` on errors: + +```python +import devolutions_crypto + +try: + # Invalid key size + result = devolutions_crypto.encrypt(b"data", b"short_key") +except devolutions_crypto.DevolutionsCryptoException as e: + print(f"Encryption error: {e}") +``` + +## Underlying Algorithms + +As of the current version: +- **Symmetric encryption**: AES-256-GCM +- **Asymmetric encryption**: X25519 (ECDH) + AES-256-GCM (ECIES) +- **Password hashing**: PBKDF2-HMAC-SHA256 +- **Digital signatures**: Ed25519 +- **Key derivation**: PBKDF2-HMAC-SHA256, Argon2 + +## Performance + +This library is built on Rust and compiled to native code, providing excellent performance: +- Symmetric encryption/decryption: Millions of operations per second +- Asymmetric operations: Thousands of operations per second +- Password hashing: Intentionally slow (configurable via iterations) + +## Contributing + +This project is open source. Visit the [GitHub repository](https://github.com/devolutions/devolutions-crypto) to report issues or contribute. + +## License + +This project is licensed under MIT OR Apache-2.0. diff --git a/python/devolutions_crypto.pyi b/python/devolutions_crypto.pyi new file mode 100644 index 00000000..03eb4608 --- /dev/null +++ b/python/devolutions_crypto.pyi @@ -0,0 +1,345 @@ +""" +Devolutions Crypto Python Bindings + +A high-performance cryptography library providing symmetric encryption, asymmetric encryption, +password hashing, digital signatures, and key derivation functions. +""" + +from typing import Optional + +class DevolutionsCryptoException(Exception): + """Base exception class for all Devolutions Crypto errors.""" + ... + +class Keypair: + """ + A container for asymmetric encryption keypair. + + Attributes: + public_key: The public key as bytes + private_key: The private key as bytes + """ + public_key: bytes + private_key: bytes + +def encrypt( + data: bytes, + key: bytes, + aad: Optional[bytes] = None, + version: int = 0 +) -> bytes: + """ + Encrypt data using symmetric encryption (AES-256-GCM). + + Args: + data: The plaintext data to encrypt + key: The encryption key (32 bytes for AES-256) + aad: Optional Additional Authenticated Data for AEAD + version: Ciphertext version (default: 0) + + Returns: + The encrypted ciphertext as bytes + + Raises: + DevolutionsCryptoException: If encryption fails or invalid parameters provided + + Example: + >>> key = b'0' * 32 # 32-byte key + >>> plaintext = b'Hello, World!' + >>> ciphertext = encrypt(plaintext, key) + """ + ... + +def decrypt( + data: bytes, + key: bytes, + aad: Optional[bytes] = None +) -> bytes: + """ + Decrypt data that was encrypted with symmetric encryption. + + Args: + data: The ciphertext to decrypt + key: The decryption key + aad: Optional Additional Authenticated Data (must match encryption AAD) + + Returns: + The decrypted plaintext as bytes + + Raises: + DevolutionsCryptoException: If decryption fails, authentication fails, or invalid ciphertext + + Example: + >>> plaintext = decrypt(ciphertext, key) + """ + ... + +def encrypt_asymmetric( + data: bytes, + key: bytes, + aad: Optional[bytes] = None, + version: int = 0 +) -> bytes: + """ + Encrypt data using asymmetric encryption (X25519 + AES-256-GCM). + + Args: + data: The plaintext data to encrypt + key: The recipient's public key + aad: Optional Additional Authenticated Data for AEAD + version: Ciphertext version (default: 0) + + Returns: + The encrypted ciphertext as bytes + + Raises: + DevolutionsCryptoException: If encryption fails or invalid public key provided + + Example: + >>> keypair = generate_keypair() + >>> ciphertext = encrypt_asymmetric(b'secret', keypair.public_key) + """ + ... + +def decrypt_asymmetric( + data: bytes, + key: bytes, + aad: Optional[bytes] = None +) -> bytes: + """ + Decrypt data that was encrypted with asymmetric encryption. + + Args: + data: The ciphertext to decrypt + key: The recipient's private key + aad: Optional Additional Authenticated Data (must match encryption AAD) + + Returns: + The decrypted plaintext as bytes + + Raises: + DevolutionsCryptoException: If decryption fails or invalid private key provided + + Example: + >>> plaintext = decrypt_asymmetric(ciphertext, keypair.private_key) + """ + ... + +def hash_password( + password: bytes, + iterations: int = 10000, + version: int = 0 +) -> bytes: + """ + Hash a password using a secure password hashing algorithm (PBKDF2). + + Args: + password: The password to hash + iterations: Number of iterations for the KDF (default: 10000, higher is more secure) + version: Password hash version (default: 0) + + Returns: + The password hash as bytes (contains salt and parameters) + + Raises: + DevolutionsCryptoException: If hashing fails or invalid parameters provided + + Example: + >>> password = b'my_secure_password' + >>> hash_value = hash_password(password, iterations=100000) + """ + ... + +def verify_password( + password: bytes, + hash: bytes +) -> bool: + """ + Verify a password against a previously generated hash. + + Args: + password: The password to verify + hash: The hash to verify against (generated by hash_password) + + Returns: + True if the password matches the hash, False otherwise + + Raises: + DevolutionsCryptoException: If the hash format is invalid + + Example: + >>> is_valid = verify_password(b'my_secure_password', hash_value) + """ + ... + +def derive_key_pbkdf2( + key: bytes, + salt: Optional[bytes] = None, + iterations: int = 10000, + length: int = 32 +) -> bytes: + """ + Derive a cryptographic key from input material using PBKDF2. + + Args: + key: The input key material + salt: Optional salt (default: empty bytes) + iterations: Number of iterations (default: 10000, higher is more secure) + length: Length of the derived key in bytes (default: 32) + + Returns: + The derived key as bytes + + Raises: + DevolutionsCryptoException: If key derivation fails + + Example: + >>> derived = derive_key_pbkdf2(b'password', b'salt', iterations=100000, length=32) + """ + ... + +def derive_key_argon2( + key: bytes, + parameters: bytes +) -> bytes: + """ + Derive a cryptographic key from input material using Argon2. + + Args: + key: The input key material + parameters: Argon2 parameters (serialized) + + Returns: + The derived key as bytes + + Raises: + DevolutionsCryptoException: If key derivation fails or invalid parameters + + Example: + >>> derived = derive_key_argon2(b'password', parameters) + """ + ... + +def generate_keypair(version: int = 0) -> Keypair: + """ + Generate a new asymmetric encryption keypair (X25519). + + Args: + version: Key version (default: 0) + + Returns: + A Keypair object containing public_key and private_key attributes + + Raises: + DevolutionsCryptoException: If keypair generation fails or invalid version + + Example: + >>> keypair = generate_keypair() + >>> public_key = keypair.public_key + >>> private_key = keypair.private_key + """ + ... + +def generate_signing_keypair(version: int = 0) -> bytes: + """ + Generate a new signing keypair (Ed25519). + + Args: + version: Key version (default: 0) + + Returns: + The signing keypair as bytes (contains both private and public key) + + Raises: + DevolutionsCryptoException: If keypair generation fails or invalid version + + Example: + >>> signing_keypair = generate_signing_keypair() + """ + ... + +def get_signing_public_key(keypair: bytes) -> bytes: + """ + Extract the public key from a signing keypair. + + Args: + keypair: The signing keypair (generated by generate_signing_keypair) + + Returns: + The public key as bytes + + Raises: + DevolutionsCryptoException: If the keypair format is invalid + + Example: + >>> public_key = get_signing_public_key(signing_keypair) + """ + ... + +def sign( + data: bytes, + keypair: bytes, + version: int = 0 +) -> bytes: + """ + Sign data using a signing keypair (Ed25519). + + Args: + data: The data to sign + keypair: The signing keypair (generated by generate_signing_keypair) + version: Signature version (default: 0) + + Returns: + The signature as bytes + + Raises: + DevolutionsCryptoException: If signing fails or invalid keypair/version + + Example: + >>> signing_keypair = generate_signing_keypair() + >>> signature = sign(b'message', signing_keypair) + """ + ... + +def verify_signature( + data: bytes, + public_key: bytes, + signature: bytes +) -> bool: + """ + Verify a signature against data using a public key. + + Args: + data: The data that was signed + public_key: The signer's public key + signature: The signature to verify + + Returns: + True if the signature is valid, False otherwise + + Raises: + DevolutionsCryptoException: If the signature or public key format is invalid + + Example: + >>> public_key = get_signing_public_key(signing_keypair) + >>> is_valid = verify_signature(b'message', public_key, signature) + """ + ... + +__all__ = [ + 'DevolutionsCryptoException', + 'Keypair', + 'encrypt', + 'decrypt', + 'encrypt_asymmetric', + 'decrypt_asymmetric', + 'hash_password', + 'verify_password', + 'derive_key_pbkdf2', + 'derive_key_argon2', + 'generate_keypair', + 'generate_signing_keypair', + 'get_signing_public_key', + 'sign', + 'verify_signature', +] diff --git a/python/pyproject.toml b/python/pyproject.toml index 812c6af1..de9fdf69 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,10 +1,34 @@ [project] name = "devolutions-crypto" +version = "0.9.3" +description = "An abstraction layer for the cryptography used by Devolutions" +readme = "PYPI_README.md" +authors = [ + { name = "Philippe Dugre", email = "pdugre@devolutions.net" }, + { name = "Mathieu Morrissette", email = "mmorrissette@devolutions.net" } +] +license = { text = "MIT OR Apache-2.0" } +requires-python = ">=3.10" +dependencies = [] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Rust", + "Topic :: Security :: Cryptography" +] + +[project.urls] +Homepage = "https://github.com/devolutions/devolutions-crypto" +Repository = "https://github.com/devolutions/devolutions-crypto" [build-system] -requires = ["maturin>=0.13"] +requires = ["maturin>=1.10"] build-backend = "maturin" [tool.maturin] bindings = "pyo3" -module-name = "devolutions_crypto" \ No newline at end of file +module-name = "devolutions_crypto" +include = ["devolutions_crypto.pyi"] \ No newline at end of file diff --git a/python/src/lib.rs b/python/src/lib.rs index 6dd5a0ce..67c68d94 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -33,251 +33,264 @@ create_exception!( #[pyclass] pub struct Keypair { #[pyo3(get)] - pub public_key: PyObject, + pub public_key: Py, #[pyo3(get)] - pub private_key: PyObject, + pub private_key: Py, } type Result = std::result::Result; -#[pymodule] -#[pyo3(name = "devolutions_crypto")] -fn devolutions_crypto_module(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { - #[pyfn(m)] - #[pyo3(name = "encrypt")] - #[pyo3(signature = (data, key, aad=None, version=0))] - fn encrypt( - py: Python, - data: &[u8], - key: &[u8], - aad: Option<&[u8]>, - version: u16, - ) -> Result> { - let version = match CiphertextVersion::try_from(version) { - Ok(v) => v, - Err(_) => { - let error: DevolutionsCryptoError = Error::UnknownVersion.into(); - return Err(error); - } - }; +#[pyfunction] +#[pyo3(name = "encrypt")] +#[pyo3(signature = (data, key, aad=None, version=0))] +fn encrypt( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, + version: u16, +) -> Result> { + let version = match CiphertextVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; - let ciphertext: Vec = match aad { - Some(aad) => ciphertext::encrypt_with_aad(data, key, aad, version)?.into(), - None => ciphertext::encrypt(data, key, version)?.into(), - }; + let ciphertext: Vec = match aad { + Some(aad) => ciphertext::encrypt_with_aad(data, key, aad, version)?.into(), + None => ciphertext::encrypt(data, key, version)?.into(), + }; - Ok(PyBytes::new(py, &ciphertext).into()) - } + Ok(PyBytes::new(py, &ciphertext).into()) +} - #[pyfn(m)] - #[pyo3(name = "encrypt_asymmetric")] - #[pyo3(signature = (data, key, aad=None, version=0))] - fn encrypt_asymmetric( - py: Python, - data: &[u8], - key: &[u8], - aad: Option<&[u8]>, - version: u16, - ) -> Result> { - let version = match CiphertextVersion::try_from(version) { - Ok(v) => v, - Err(_) => { - let error: DevolutionsCryptoError = Error::UnknownVersion.into(); - return Err(error); - } - }; +#[pyfunction] +#[pyo3(name = "encrypt_asymmetric")] +#[pyo3(signature = (data, key, aad=None, version=0))] +fn encrypt_asymmetric( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, + version: u16, +) -> Result> { + let version = match CiphertextVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; - let key = PublicKey::try_from(key)?; + let key = PublicKey::try_from(key)?; - let ciphertext: Vec = match aad { - Some(aad) => ciphertext::encrypt_asymmetric_with_aad(data, &key, aad, version)?.into(), - None => ciphertext::encrypt_asymmetric(data, &key, version)?.into(), - }; + let ciphertext: Vec = match aad { + Some(aad) => ciphertext::encrypt_asymmetric_with_aad(data, &key, aad, version)?.into(), + None => ciphertext::encrypt_asymmetric(data, &key, version)?.into(), + }; - Ok(PyBytes::new(py, &ciphertext).into()) - } + Ok(PyBytes::new(py, &ciphertext).into()) +} - #[pyfn(m)] - #[pyo3(name = "hash_password")] - #[pyo3(signature = (password, iterations=10000, version=0))] - fn hash_password( - py: Python, - password: &[u8], - iterations: u32, - version: u16, - ) -> Result> { - let version = - match devolutions_crypto::password_hash::PasswordHashVersion::try_from(version) { - Ok(v) => v, - Err(_) => { - let error: DevolutionsCryptoError = Error::UnknownVersion.into(); - return Err(error); - } - }; - - let hash: Vec = - devolutions_crypto::password_hash::hash_password(password, iterations, version)?.into(); - Ok(PyBytes::new(py, &hash).into()) - } +#[pyfunction] +#[pyo3(name = "hash_password")] +#[pyo3(signature = (password, iterations=10000, version=0))] +fn hash_password( + py: Python, + password: &[u8], + iterations: u32, + version: u16, +) -> Result> { + let version = match devolutions_crypto::password_hash::PasswordHashVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; - #[pyfn(m)] - #[pyo3(name = "verify_password")] - fn verify_password(py: Python, password: &[u8], hash: &[u8]) -> Result> { - let res = devolutions_crypto::password_hash::PasswordHash::try_from(hash)?; + let hash: Vec = + devolutions_crypto::password_hash::hash_password(password, iterations, version)?.into(); + Ok(PyBytes::new(py, &hash).into()) +} - Ok(PyBool::new(py, res.verify_password(password)) - .to_owned() - .into()) - } +#[pyfunction] +#[pyo3(name = "verify_password")] +fn verify_password(py: Python, password: &[u8], hash: &[u8]) -> Result> { + let res = devolutions_crypto::password_hash::PasswordHash::try_from(hash)?; - #[pyfn(m)] - #[pyo3(name = "decrypt")] - #[pyo3(signature = (data, key, aad=None))] - fn decrypt(py: Python, data: &[u8], key: &[u8], aad: Option<&[u8]>) -> Result> { - let ciphertext: Ciphertext = ciphertext::Ciphertext::try_from(data)?; - let plaintext: Vec = match aad { - Some(aad) => ciphertext.decrypt_with_aad(key, aad)?.into(), - None => ciphertext.decrypt(key)?.into(), - }; - - Ok(PyBytes::new(py, &plaintext).into()) - } + Ok(PyBool::new(py, res.verify_password(password)) + .to_owned() + .into()) +} - #[pyfn(m)] - #[pyo3(name = "decrypt_asymmetric")] - #[pyo3(signature = (data, key, aad=None))] - fn decrypt_asymmetric( - py: Python, - data: &[u8], - key: &[u8], - aad: Option<&[u8]>, - ) -> Result> { - let ciphertext: Ciphertext = ciphertext::Ciphertext::try_from(data)?; - let key: PrivateKey = PrivateKey::try_from(key)?; - let plaintext: Vec = match aad { - Some(aad) => ciphertext.decrypt_asymmetric_with_aad(&key, aad)?.into(), - None => ciphertext.decrypt_asymmetric(&key)?.into(), - }; - - Ok(PyBytes::new(py, &plaintext).into()) - } +#[pyfunction] +#[pyo3(name = "decrypt")] +#[pyo3(signature = (data, key, aad=None))] +fn decrypt(py: Python, data: &[u8], key: &[u8], aad: Option<&[u8]>) -> Result> { + let ciphertext: Ciphertext = ciphertext::Ciphertext::try_from(data)?; + let plaintext: Vec = match aad { + Some(aad) => ciphertext.decrypt_with_aad(key, aad)?.into(), + None => ciphertext.decrypt(key)?.into(), + }; + + Ok(PyBytes::new(py, &plaintext).into()) +} - #[pyfn(m)] - #[pyo3(name = "derive_key_pbkdf2")] - #[pyo3(signature = (key, salt=None, iterations=10000, length=32))] - fn derive_key_pbkdf2( - py: Python, - key: &[u8], - salt: Option>, - iterations: u32, - length: usize, - ) -> Result> { - let salt = salt.unwrap_or_else(|| vec![0u8; 0]); - - let key = utils::derive_key_pbkdf2(key, &salt, iterations, length); - Ok(PyBytes::new(py, &key).into()) - } +#[pyfunction] +#[pyo3(name = "decrypt_asymmetric")] +#[pyo3(signature = (data, key, aad=None))] +fn decrypt_asymmetric( + py: Python, + data: &[u8], + key: &[u8], + aad: Option<&[u8]>, +) -> Result> { + let ciphertext: Ciphertext = ciphertext::Ciphertext::try_from(data)?; + let key: PrivateKey = PrivateKey::try_from(key)?; + let plaintext: Vec = match aad { + Some(aad) => ciphertext.decrypt_asymmetric_with_aad(&key, aad)?.into(), + None => ciphertext.decrypt_asymmetric(&key)?.into(), + }; + + Ok(PyBytes::new(py, &plaintext).into()) +} + +#[pyfunction] +#[pyo3(name = "derive_key_pbkdf2")] +#[pyo3(signature = (key, salt=None, iterations=10000, length=32))] +fn derive_key_pbkdf2( + py: Python, + key: &[u8], + salt: Option>, + iterations: u32, + length: usize, +) -> Result> { + let salt = salt.unwrap_or_else(|| vec![0u8; 0]); + + let key = utils::derive_key_pbkdf2(key, &salt, iterations, length); + Ok(PyBytes::new(py, &key).into()) +} - #[pyfn(m)] - #[pyo3(name = "derive_key_argon2")] - fn derive_key_argon2(py: Python, key: &[u8], parameters: &[u8]) -> Result> { - let parameters = Argon2Parameters::try_from(parameters)?; +#[pyfunction] +#[pyo3(name = "derive_key_argon2")] +fn derive_key_argon2(py: Python, key: &[u8], parameters: &[u8]) -> Result> { + let parameters = Argon2Parameters::try_from(parameters)?; - let key = utils::derive_key_argon2(key, ¶meters)?; - Ok(PyBytes::new(py, &key).into()) - } + let key = utils::derive_key_argon2(key, ¶meters)?; + Ok(PyBytes::new(py, &key).into()) +} - #[pyfn(m)] - #[pyo3(name = "sign")] - #[pyo3(signature = (data, keypair, version=0))] - fn sign(py: Python, data: &[u8], keypair: &[u8], version: u16) -> Result> { - let version = match SignatureVersion::try_from(version) { - Ok(v) => v, - Err(_) => { - let error: DevolutionsCryptoError = Error::UnknownVersion.into(); - return Err(error); - } - }; +#[pyfunction] +#[pyo3(name = "sign")] +#[pyo3(signature = (data, keypair, version=0))] +fn sign(py: Python, data: &[u8], keypair: &[u8], version: u16) -> Result> { + let version = match SignatureVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; - let keypair = SigningKeyPair::try_from(keypair)?; + let keypair = SigningKeyPair::try_from(keypair)?; - let signature: Vec = signature::sign(data, &keypair, version).into(); - Ok(PyBytes::new(py, &signature).into()) - } + let signature: Vec = signature::sign(data, &keypair, version).into(); + Ok(PyBytes::new(py, &signature).into()) +} - #[pyfn(m)] - #[pyo3(name = "verify_signature")] - fn verify_signature( - py: Python, - data: &[u8], - public_key: &[u8], - signature: &[u8], - ) -> Result> { - let public_key = SigningPublicKey::try_from(public_key)?; - let signature = Signature::try_from(signature)?; - - Ok(PyBool::new(py, signature.verify(data, &public_key)) - .to_owned() - .into()) - } +#[pyfunction] +#[pyo3(name = "verify_signature")] +fn verify_signature( + py: Python, + data: &[u8], + public_key: &[u8], + signature: &[u8], +) -> Result> { + let public_key = SigningPublicKey::try_from(public_key)?; + let signature = Signature::try_from(signature)?; + + Ok(PyBool::new(py, signature.verify(data, &public_key)) + .to_owned() + .into()) +} - #[pyfn(m)] - #[pyo3(name = "generate_keypair")] - #[pyo3(signature = (version=0))] - fn generate_keypair(py: Python, version: u16) -> Result { - let version = match KeyVersion::try_from(version) { - Ok(v) => v, - Err(_) => { - let error: DevolutionsCryptoError = Error::UnknownVersion.into(); - return Err(error); - } - }; +#[pyfunction] +#[pyo3(name = "generate_keypair")] +#[pyo3(signature = (version=0))] +fn generate_keypair(py: Python, version: u16) -> Result { + let version = match KeyVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; - let kp = key::generate_keypair(version); + let kp = key::generate_keypair(version); - let private_key: Vec = kp.private_key.into(); - let public_key: Vec = kp.public_key.into(); + let private_key: Vec = kp.private_key.into(); + let public_key: Vec = kp.public_key.into(); - let keypair = Keypair { - private_key: PyBytes::new(py, &private_key).into(), - public_key: PyBytes::new(py, &public_key).into(), - }; + let keypair = Keypair { + private_key: PyBytes::new(py, &private_key).into(), + public_key: PyBytes::new(py, &public_key).into(), + }; - Ok(keypair) - } + Ok(keypair) +} - #[pyfn(m)] - #[pyo3(name = "generate_signing_keypair")] - #[pyo3(signature = (version=0))] - fn generate_signing_keypair(py: Python, version: u16) -> Result> { - let version = match SigningKeyVersion::try_from(version) { - Ok(v) => v, - Err(_) => { - let error: DevolutionsCryptoError = Error::UnknownVersion.into(); - return Err(error); - } - }; +#[pyfunction] +#[pyo3(name = "generate_signing_keypair")] +#[pyo3(signature = (version=0))] +fn generate_signing_keypair(py: Python, version: u16) -> Result> { + let version = match SigningKeyVersion::try_from(version) { + Ok(v) => v, + Err(_) => { + let error: DevolutionsCryptoError = Error::UnknownVersion.into(); + return Err(error); + } + }; - let kp = signing_key::generate_signing_keypair(version); + let kp = signing_key::generate_signing_keypair(version); - let kp: Vec = kp.into(); + let kp: Vec = kp.into(); - Ok(PyBytes::new(py, &kp).into()) - } + Ok(PyBytes::new(py, &kp).into()) +} - #[pyfn(m)] - #[pyo3(name = "get_signing_public_key")] - fn get_signing_public_key(py: Python, keypair: &[u8]) -> Result> { - let keypair = SigningKeyPair::try_from(keypair)?; +#[pyfunction] +#[pyo3(name = "get_signing_public_key")] +fn get_signing_public_key(py: Python, keypair: &[u8]) -> Result> { + let keypair = SigningKeyPair::try_from(keypair)?; - let public_key: Vec = keypair.get_public_key().into(); + let public_key: Vec = keypair.get_public_key().into(); - Ok(PyBytes::new(py, &public_key).into()) - } + Ok(PyBytes::new(py, &public_key).into()) +} +#[pymodule] +#[pyo3(name = "devolutions_crypto")] +fn devolutions_crypto_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(encrypt, m)?)?; + m.add_function(wrap_pyfunction!(decrypt, m)?)?; + m.add_function(wrap_pyfunction!(encrypt_asymmetric, m)?)?; + m.add_function(wrap_pyfunction!(decrypt_asymmetric, m)?)?; + m.add_function(wrap_pyfunction!(hash_password, m)?)?; + m.add_function(wrap_pyfunction!(verify_password, m)?)?; + m.add_function(wrap_pyfunction!(derive_key_pbkdf2, m)?)?; + m.add_function(wrap_pyfunction!(derive_key_argon2, m)?)?; + m.add_function(wrap_pyfunction!(sign, m)?)?; + m.add_function(wrap_pyfunction!(verify_signature, m)?)?; + m.add_function(wrap_pyfunction!(generate_keypair, m)?)?; + m.add_function(wrap_pyfunction!(generate_signing_keypair, m)?)?; + m.add_function(wrap_pyfunction!(get_signing_public_key, m)?)?; + m.add_class::()?; m.add( "DevolutionsCryptoException", - py.get_type::(), + m.py().get_type::(), )?; Ok(())