29 Commits
Author SHA1 Message Date
Leo Vasanko 1ecf0d306f Make tool scripts executable. 2025-12-31 21:43:25 +00:00
Leo Vasanko 5824df28cb Add aeg.cipher(alg) for loading modules by name in str. Added aeg.CIPHERS mapping of the nominal names to module names and Cipher for typing literals. 2025-12-31 21:42:29 +00:00
Leo Vasanko 938239ef72 Added free-threaded non-GIL Python and PyPy wheels, up to 3.15 now. 2025-12-31 21:24:56 +00:00
Leo Vasanko f3c0b2b85d [release script] Auditwheel only on Linux. 2025-12-23 19:46:13 +00:00
Leo Vasanko dd0c0bc0a0 Add README for PyPI. 2025-12-23 19:33:00 +00:00
Leo Vasanko 40560d2deb Make manylinux wheels. 2025-12-23 19:32:03 +00:00
Leo Vasanko 6fc3098650 Clean up for release. 2025-12-23 19:09:59 +00:00
Leo Vasanko ff97bf42a8 Clean up for release. 2025-12-23 19:08:10 +00:00
Leo Vasanko ba2179e827 Clean up for release. 2025-12-23 18:54:03 +00:00
Leo Vasanko 640e812908 Project renamed to aeg, preparing for PyPI release. 2025-12-23 18:25:03 +00:00
Leo Vasanko a644d27b56 Add tools/release.py script for automatic release process. 2025-11-12 11:47:26 -06:00
Leo Vasanko 4445c256b1 Always generate LF line endings (even on broken OS). 2025-11-12 11:43:25 -06:00
Leo Vasanko 04f1c3067c Ruff formatting. 2025-11-12 11:43:04 -06:00
Leo Vasanko 39e5f00f42 Fixed Windows build, cleaned up messages during build, using SCM version numbering, added ruff. 2025-11-12 10:59:21 -06:00
Leo Vasanko 88efc4cabc Update pyproject, bump version. 2025-11-09 20:41:40 -06:00
Leo Vasanko 04b11e9925 README tuning. New benchmark results (a bit slower than initial versions were). 2025-11-09 20:40:04 -06:00
Leo Vasanko d8a9a7ee9d Use a much faster method to wipe buffers. 2025-11-09 20:38:28 -06:00
Leo Vasanko f5430a6ad4 Cleanup. 2025-11-09 20:00:12 -06:00
Leo Vasanko f84ef727d3 Bump version 2025-11-09 09:47:08 -06:00
Leo Vasanko bb9d11842a Convert all input buffers to memoryview before use and use .nbytes, because len() doesn't work correctly with some buffers. Update docs with a Numpy example. 2025-11-09 09:46:21 -06:00
Leo Vasanko 20e0ed8c5f Cleanup benchmark 2025-11-09 08:59:53 -06:00
Leo Vasanko 62fc8fa855 Make Mac class prevent further updates or final after finalisation. Keep cached values for hashlib API. 2025-11-09 08:53:50 -06:00
Leo Vasanko 67c2958384 Cleanup 2025-11-08 20:39:41 -06:00
Leo Vasanko a6faaf9f62 Simplify implementation: remove bytes_in and bytes_out counters from all classes. 2025-11-08 20:09:26 -06:00
Leo Vasanko 75cbc76845 Wipe state structs automatically after use. Simplified aligned allocator and its use via a single handle. 2025-11-08 20:04:39 -06:00
Leo Vasanko 95563a43d1 API updates:
- Mac class follows hashlib API: digest functions added and finalization no longer modifies state.
- Encryptor and Decryptor now raise RuntimeError if still used after final.

Documentation updated with the changes and  further examples.

Tests updated with the changes, new test module for error cases (test_raises).

Docstrings improved.
2025-11-08 18:53:27 -06:00
Leo Vasanko e58990a1c2 Add human-readable algorithm name as NAME constant. 2025-11-08 16:01:05 -06:00
Leo Vasanko 13445887e9 Constants renamed and values extracted from C code rather than function call at runtime. Documentation update. 2025-11-08 15:43:01 -06:00
Leo Vasanko 751a929836 Combine the two generator scripts into one that also reads ALIGNMENT and RATE from C sources. 2025-11-08 13:20:15 -06:00
31 changed files with 3918 additions and 2325 deletions
+3 -3
View File
@@ -3,9 +3,9 @@
*.egg-info *.egg-info
/dist /dist
/build /build
/pyaegis/build /src/aeg/build
/pyaegis/_aegis.*.so /src/aeg/_aegis*.so
/pyaegis/_aegis.*.pyd /src/aeg/_aegis*.pyd
__pycache__ __pycache__
!.gitignore !.gitignore
!.gitmodules !.gitmodules
+8 -14
View File
@@ -1,6 +1,6 @@
# Building pyaegis # Building aeg
This document contains instructions for developers who want to build pyaegis from source. This document contains instructions for developers who want to build aeg from source.
## Prerequisites ## Prerequisites
@@ -11,7 +11,7 @@ This document contains instructions for developers who want to build pyaegis fro
### Installing Zig ### Installing Zig
pyaegis uses Zig to build the underlying libaegis C library. Install Zig from [ziglang.org/download](https://ziglang.org/download/) or using your package manager: aeg uses Zig to build the underlying libaegis C library. Install Zig from [ziglang.org/download](https://ziglang.org/download/) or using your package manager:
- **macOS**: `brew install zig` - **macOS**: `brew install zig`
- **Linux**: See [Zig installation guide](https://github.com/ziglang/zig/wiki/Install-Zig-from-a-Package-Manager) - **Linux**: See [Zig installation guide](https://github.com/ziglang/zig/wiki/Install-Zig-from-a-Package-Manager)
@@ -22,8 +22,8 @@ pyaegis uses Zig to build the underlying libaegis C library. Install Zig from [z
Clone the repository with submodules: Clone the repository with submodules:
```fish ```fish
git clone --recursive https://github.com/LeoVasanko/pyaegis.git git clone --recursive https://github.com/LeoVasanko/aeg.git
cd pyaegis cd aeg
``` ```
If you already cloned without `--recursive`, initialize submodules: If you already cloned without `--recursive`, initialize submodules:
@@ -74,16 +74,10 @@ This creates files in the `dist/` directory.
## Code Generation ## Code Generation
The Python modules are generated from templates. If you modify the core implementation in `pyaegis/aegis256x4.py`, regenerate the other variants: The Python modules and CFFI definitions are generated from C sources and templates. If you modify the core implementation in `src/aeg/aegis256x4.py` or update libaegis headers, regenerate all files:
```fish ```fish
python tools/gen_modules.py python tools/generate.py
```
If you update libaegis headers, regenerate the CFFI definitions:
```fish
python tools/gen_cdef.py
``` ```
## Troubleshooting ## Troubleshooting
@@ -106,7 +100,7 @@ If you cannot install Zig, you may manually compile in the libaegis folder (Zig,
## Project Structure ## Project Structure
- `pyaegis/` - Python package source - `src/aeg/` - Python package source
- `libaegis/` - C library source (submodule) - `libaegis/` - C library source (submodule)
- `tests/` - Test suite - `tests/` - Test suite
- `tools/` - Code generation scripts and `build_backend.py` used to build libaegis - `tools/` - Code generation scripts and `build_backend.py` used to build libaegis
+1 -3
View File
@@ -1,13 +1,11 @@
include pyaegis/aegis_cdef.h include src/aeg/aegis_cdef.h
include setup.py include setup.py
include tools/build_backend.py include tools/build_backend.py
include BUILD.md include BUILD.md
include README.md include README.md
recursive-include libaegis *.c *.h *.zig *.zon recursive-include libaegis *.c *.h *.zig *.zon
include libaegis/CMakeLists.txt
include libaegis/LICENSE include libaegis/LICENSE
include libaegis/README.md include libaegis/README.md
recursive-include libaegis/cmake *.cmake *.cmake.in
graft libaegis/src graft libaegis/src
include libaegis/build.zig include libaegis/build.zig
include libaegis/build.zig.zon include libaegis/build.zig.zon
+125 -74
View File
@@ -1,33 +1,41 @@
# pyaegis # AEGIS Cipher Python Binding
[![PyPI version](https://badge.fury.io/py/pyaegis.svg)](https://badge.fury.io/py/pyaegis) [![PyPI version](https://badge.fury.io/py/aeg.svg)](https://badge.fury.io/py/aeg)
Safe Python bindings for the AEGIS family of very fast authenticated encryption algorithms (via libaegis). Safe Python bindings for the AEGIS family of very fast authenticated encryption algorithms via libaegis. The module runs without compilation required on Windows, Mac and Linux (has precompiled wheels). For other platforms compilation is performed at install time.
AEGIS enables extremely fast Encryption, MAC and CSPRNG - many times faster than AES, ChaCha20 or traditional random number generators. Authenticated Encryption with Additional Data is supported with the MAC derived from the cipher state at the end, making it different from other AEADs like AES-GCM and ChaCha20-Poly1305. The whole internal state thus depends on the prior data, and it is neither Encrypt-Then-Mac nor Mac-The-Encrypt scheme when both features are used together.
## Install ## Install
Using [uv](https://docs.astral.sh/uv/getting-started/installation/): ```sh
```fish pip install aeg
uv pip install git+https://github.com/LeoVasanko/pyaegis.git
``` ```
For development builds, see BUILD.md. Or add to your project using [UV](https://docs.astral.sh/uv/getting-started/installation/):
```sh
uv add aeg
```
## Variants ## Variants
All submodules expose the same API; pick one for your key/nonce size and platform: All submodules expose the same API; pick one for your needs. The 256 bit variants offer maximal security and use larger key and nonce, while the 128 bit variants run slightly faster and use smaller key and nonce while still providing strong security. The MAC length does not depend on the variant. Note that the x2 and x4 variants are typically the fastest (depending on CPU) by utilizing SIMD multi-lane processing for the highest throughput.
- aegis128l (16-byte key, 16-byte nonce) | Variant | Key/Nonce Bytes | Notes |
- aegis256 (32-byte key, 32-byte nonce) |----------------|----------------:|-------------------------|
- aegis128x2 / aegis128x4 (multi-lane 128-bit; best throughput on SIMD-capable CPUs) | **aegis128l** | 16 | |
- aegis256x2 / aegis256x4 (multi-lane 256-bit) | **aegis128x2** | 16 | Fastest on Intel Core |
| **aegis128x4** | 16 | Fastest on AMD and Xeon |
| **aegis256** | 32 | |
| **aegis256x2** | 32 | Fast on Intel Core |
| **aegis256x4** | 32 | Fast on AMD and Xeon |
## Quick start ## Quick start
Normal authenticated encryption using the AEGIS-128X4 algorithm: Normal authenticated encryption using the AEGIS-128X4 algorithm:
```python ```python
from pyaegis import aegis128x4 as ciph from aeg import aegis128x4 as ciph
key = ciph.random_key() # Secret key (stored securely) key = ciph.random_key() # Secret key (stored securely)
nonce = ciph.random_nonce() # Public nonce (recreated for each message) nonce = ciph.random_nonce() # Public nonce (recreated for each message)
@@ -42,14 +50,14 @@ assert pt == msg
Common parameters and returns (applies to all items below): Common parameters and returns (applies to all items below):
- key: bytes of length a.KEYBYTES - key: bytes of length ciph.KEYBYTES
- nonce: bytes of length a.NPUBBYTES (must be unique per (key, message)) - nonce: bytes of length ciph.NONCEBYTES (must be unique per message)
- message/ct: plain text or ciphertext - message/ct: plain text or ciphertext
- ad: optional associated data (authenticated, not encrypted) - ad: optional associated data (authenticated, not encrypted)
- into: optional output buffer (see below) - into: optional output buffer (see below)
- maclen: MAC tag length 16 or 32 bytes (default 16) - maclen: MAC tag length 16 or 32 bytes (default 16)
Only the first few can be positional arguments that are always provided in this order. All arguments can be passed as kwargs. The inputs can be any Buffer supporting len() (e.g. `bytes`, `bytearray`, `memoryview`). Only the first few can be positional arguments that are always provided in this order. All arguments can be passed as kwargs. The inputs can be any Buffer (e.g. `bytes`, `bytearray`, `memoryview`).
Most functions return a buffer of bytes. By default a `bytearray` of the correct size is returned. An existing buffer can be provided by `into` argument, in which case the bytes of it that were written to are returned as a memoryview. Most functions return a buffer of bytes. By default a `bytearray` of the correct size is returned. An existing buffer can be provided by `into` argument, in which case the bytes of it that were written to are returned as a memoryview.
@@ -70,22 +78,30 @@ No MAC tag, vulnerable to alterations:
### Incremental AEAD ### Incremental AEAD
Stateful classes that can be used for processing the data in separate chunks: Stateful classes that can be used for processing the data in separate chunks:
- Encryptor(key, nonce, ad=None) - Encryptor(key, nonce, ad=None, maclen=16)
- update(message[, into]) -> ciphertext_chunk - update(message[, into]) -> ciphertext_chunk
- final([into], maclen=16) -> mac_tag - final([into]) -> mac_tag
- Decryptor(key, nonce, ad=None) - Decryptor(key, nonce, ad=None, maclen=16)
- update(ct_chunk[, into]) -> plaintext_chunk - update(ct_chunk[, into]) -> plaintext_chunk
- final(mac) -> None (raises ValueError on failure) - final(mac) -> raises ValueError on failure
The object releases its state and becomes unusable after final has been called.
### Message Authentication Code ### Message Authentication Code
No encryption, but prevents changes to the data without the correct key. No encryption, but prevents changes to the data without the correct key.
- mac(key, nonce, data, maclen=16, into=None) -> mac - mac(key, nonce, data, maclen=16, into=None) -> mac bytes
- Mac(key, nonce) - Mac(key, nonce, maclen=16)
- update(data) - update(data)
- final(maclen=16[, into]) -> mac - final([into]) -> mac bytes
- verify(mac) -> bool (True on success; raises ValueError on failure) - verify(mac) -> raises ValueError on failure
- digest() -> mac bytes
- hexdigest() -> mac str
- reset()
- clone() -> Mac
The `Mac` class follows the Python hashlib API for compatibility with code expecting hash objects. After calling `final()`, `digest()`, or `hexdigest()`, the Mac object becomes unusable for further `update()` operations. However, `digest()` and `hexdigest()` cache their results and can be called multiple times. Use `reset()` to clear the state and start over, or `clone()` to create a copy before finalizing.
### Keystream generation ### Keystream generation
@@ -95,10 +111,10 @@ Useful for creating pseudo random bytes as rapidly as possible. Reuse of the sam
### Miscellaneous ### Miscellaneous
Constants (per module): KEYBYTES, NPUBBYTES, ABYTES_MIN, ABYTES_MAX, RATE, ALIGNMENT Constants (per module): NAME, KEYBYTES, NONCEBYTES, MACBYTES, MACBYTES_LONG, RATE, ALIGNMENT
- random_key() -> bytearray (length KEYBYTES) - random_key() -> bytearray (length KEYBYTES)
- random_nonce() -> bytearray (length NPUBBYTES) - random_nonce() -> bytearray (length NONCEBYTES)
- nonce_increment(nonce) - nonce_increment(nonce)
- wipe(buffer) - wipe(buffer)
@@ -115,16 +131,22 @@ Constants (per module): KEYBYTES, NPUBBYTES, ABYTES_MIN, ABYTES_MAX, RATE, ALIGN
A cryptographically secure keyed hash is produced. The example uses all zeroes for the nonce to always produce the same hash for the same key: A cryptographically secure keyed hash is produced. The example uses all zeroes for the nonce to always produce the same hash for the same key:
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), bytes(ciph.NPUBBYTES) key, nonce = ciph.random_key(), bytes(ciph.NONCEBYTES)
mac = ciph.mac(key, nonce, b"message", maclen=32) mac = ciph.mac(key, nonce, b"message", maclen=32)
print(mac) print(mac.hex())
st = ciph.Mac(key, nonce) # Alternative class-based API
st.update(b"message") a = ciph.Mac(key, nonce, maclen=32)
st.update(b"Mallory Says Hello!") a.update(b"message")
st.verify(mac) # Raises ValueError print(a.hexdigest())
# Verification
b = ciph.Mac(key, nonce, maclen=32)
b.update(b"message")
b.update(b"Mallory Says Hello!")
b.verify(mac) # Raises ValueError
``` ```
### Detached mode encryption and decryption ### Detached mode encryption and decryption
@@ -132,7 +154,7 @@ st.verify(mac) # Raises ValueError
Keeping the ciphertext, mac and ad separate. The ad represents a file header that needs to be tamper proofed. Keeping the ciphertext, mac and ad separate. The ad represents a file header that needs to be tamper proofed.
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
ct, mac = ciph.encrypt_detached(key, nonce, b"secret", ad=b"header") ct, mac = ciph.encrypt_detached(key, nonce, b"secret", ad=b"header")
@@ -148,15 +170,15 @@ ciph.wipe(pt)
Class-based interface for incremental updates is an alternative to the one-shot functions. Not to be confused with separately verified ciphertext frames (see the next example). Class-based interface for incremental updates is an alternative to the one-shot functions. Not to be confused with separately verified ciphertext frames (see the next example).
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
enc = a.Encryptor(key, nonce, ad=b"header") enc = ciph.Encryptor(key, nonce, ad=b"header", maclen=16)
c1 = enc.update(b"chunk1") c1 = enc.update(b"chunk1")
c2 = enc.update(b"chunk2") c2 = enc.update(b"chunk2")
mac = enc.final(maclen=16) mac = enc.final()
dec = a.Decryptor(key, nonce, ad=b"header") dec = ciph.Decryptor(key, nonce, ad=b"header", maclen=16)
p1 = dec.update(c1) p1 = dec.update(c1)
p2 = dec.update(c2) p2 = dec.update(c2)
dec.final(mac) # raises ValueError on failure dec.final(mac) # raises ValueError on failure
@@ -167,16 +189,17 @@ dec.final(mac) # raises ValueError on failure
It is often practical to split larger messages into frames that can be individually decrypted and verified. Because every frame needs a different key, we employ the `nonce_increment` utility function to produce sequential nonces for each frame. As for the AEGIS algorithm, each frame is a completely independent invocation. The program will each time produce a completely different random-looking encrypted.bin file. It is often practical to split larger messages into frames that can be individually decrypted and verified. Because every frame needs a different key, we employ the `nonce_increment` utility function to produce sequential nonces for each frame. As for the AEGIS algorithm, each frame is a completely independent invocation. The program will each time produce a completely different random-looking encrypted.bin file.
```python ```python
from pyaegis import aegis128x4 as ciph # Encryption settings
from aeg import aegis128x4 as ciph
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms
framebytes = 80 # In real applications 1 MiB or more is practical
maclen = ciph.MACBYTES # 16
message = bytearray(30 * b"Attack at dawn! ") message = bytearray(30 * b"Attack at dawn! ")
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms
nonce = ciph.random_nonce()
framebytes = 80 # In real applications 1 MiB or more is practical
maclen = ciph.ABYTES_MIN # 16
with open("encrypted.bin", "wb") as f: with open("encrypted.bin", "wb") as f:
f.write(nonce) # Public initial nonce sent with the ciphertext # Public initial nonce sent with the ciphertext
nonce = ciph.random_nonce()
f.write(nonce)
while message: while message:
chunk = message[:framebytes - maclen] chunk = message[:framebytes - maclen]
del message[:len(chunk)] del message[:len(chunk)]
@@ -186,15 +209,14 @@ with open("encrypted.bin", "wb") as f:
``` ```
```python ```python
from pyaegis import aegis128x4 as ciph
# Decryption needs same values as encryption # Decryption needs same values as encryption
from aeg import aegis128x4 as ciph
key = b"sixteenbyte key!" key = b"sixteenbyte key!"
framebytes = 80 framebytes = 80
maclen = ciph.ABYTES_MIN maclen = ciph.MACBYTES
with open("encrypted.bin", "rb") as f: with open("encrypted.bin", "rb") as f:
nonce = bytearray(f.read(ciph.NPUBBYTES)) nonce = bytearray(f.read(ciph.NONCEBYTES))
while True: while True:
frame = f.read(framebytes) frame = f.read(framebytes)
if not frame: if not frame:
@@ -204,16 +226,47 @@ with open("encrypted.bin", "rb") as f:
print(pt) print(pt)
``` ```
### Random generator
The stream generator is much faster than any traditional random number generator, cryptographically secure and seekable. Use `random_key()` for unpredictable output.
```python
from aeg import aegis128x4 as ciph
key = b"SeedForReplay001" # A non-random deterministic seed (16 bytes)
nonce = bytearray(ciph.NONCEBYTES) # All-zeroes nonce
# Generate multiple blocks of pseudorandom data
for i in range(5):
rand = ciph.stream(key, nonce, 10)
print(f"Block {int.from_bytes(nonce, "little")}: {rand.hex()}")
ciph.nonce_increment(nonce)
```
Note: this is seekable by converting the block number to nonce with `idx.to_bytes(ciph.NONCEBYTES, "little")`, given some fixed block size (e.g. 1 MiB).
### Preallocated output buffers (into=) ### Preallocated output buffers (into=)
For advanced use cases, the output buffer can be supplied with `into` kwarg. Any type of writable buffer with len() >= space required can be used. This includes bytearrays, memoryviews, mmap files, numpy.getbuffer etc. For advanced use cases, the output buffer can be supplied with `into` kwarg. Any type of writable buffer with a sufficient number of bytes can be used. This includes bytearrays, memoryviews, mmap files, numpy arrays etc.
A `TypeError` is raised if the buffer is too small. For convenience, the functions return a memoryview showing only the bytes actually written. A `TypeError` is raised if the buffer is too small. For convenience, the functions return a memoryview showing only the bytes actually written.
Foreign arrays can be used. This example fills a Numpy array with random integers.
```python
import numpy as np
from aeg import aegis128x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce()
arr = np.empty(10, dtype=np.uint64) # Uninitialised integer array
ciph.stream(key, nonce, into=arr) # Fill with random bytes
print(arr)
```
In-place operations are supported when the input and the output point to the same location in memory. When using attached MAC tag, the input buffer needs to be sliced to correct length: In-place operations are supported when the input and the output point to the same location in memory. When using attached MAC tag, the input buffer needs to be sliced to correct length:
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
buf = memoryview(bytearray(1000)) # memoryview[:len] is still in the same buffer (no copy) buf = memoryview(bytearray(1000)) # memoryview[:len] is still in the same buffer (no copy)
buf[:7] = b"message" buf[:7] = b"message"
@@ -231,33 +284,27 @@ Detached and unauthenticated modes can use same size input and output (no MAC ad
Runtime CPU feature detection selects optimized code paths (AES-NI, ARM Crypto, AVX2/AVX-512). Multi-lane variants (x2/x4) offer higher throughput on suitable CPUs. Runtime CPU feature detection selects optimized code paths (AES-NI, ARM Crypto, AVX2/AVX-512). Multi-lane variants (x2/x4) offer higher throughput on suitable CPUs.
Run the built-in benchmark to see which variant is fastest on your machine: Benchmarks using the included benchmark module, run on Intel i7-14700, linux, single core (the software is not multithreaded). Note that the results are in megabits per second, not bytes. The CPU lacks AVX-512 that makes the X4 variants faster on processors supporting it (most AMD, Xeon).
```fish ```sh
uv run -m pyaegis.benchmark uv run -m aeg.benchmark
``` AEGIS-256 103166.24 Mb/s
AEGIS-256X2 184225.50 Mb/s
Benchmarks of the Python module and the C library run on Intel i7-14700, linux, single core (the software is not multithreaded). Note that the results are in megabits per second, not bytes. The CPU lacks AVX-512 that makes the X4 variants faster on AMD hardware. AEGIS-256X4 194018.26 Mb/s
AEGIS-128L 161551.73 Mb/s
```fish AEGIS-128X2 281987.80 Mb/s
$ python -m pyaegis.benchmark AEGIS-128X4 217997.37 Mb/s
AEGIS-256 107666.56 Mb/s AEGIS-128L MAC 188886.40 Mb/s
AEGIS-256X2 191314.53 Mb/s AEGIS-128X2 MAC 306457.97 Mb/s
AEGIS-256X4 211537.44 Mb/s AEGIS-128X4 MAC 299576.59 Mb/s
AEGIS-128L 159074.08 Mb/s AEGIS-256 MAC 100914.04 Mb/s
AEGIS-128X2 307332.53 Mb/s AEGIS-256X2 MAC 190208.20 Mb/s
AEGIS-128X4 230106.70 Mb/s AEGIS-256X4 MAC 315919.87 Mb/s
AEGIS-128L MAC 206082.24 Mb/s
AEGIS-128X2 MAC 366401.20 Mb/s
AEGIS-128X4 MAC 375011.51 Mb/s
AEGIS-256 MAC 110187.03 Mb/s
AEGIS-256X2 MAC 210063.51 Mb/s
AEGIS-256X4 MAC 347406.96 Mb/s
``` ```
The Python library performance is similar to that of the C library: The Python library performance is similar to that of the C library:
```fish ```sh
$ ./libaegis/zig-out/bin/benchmark ./libaegis/zig-out/bin/benchmark
AEGIS-256 107820.86 Mb/s AEGIS-256 107820.86 Mb/s
AEGIS-256X2 205025.57 Mb/s AEGIS-256X2 205025.57 Mb/s
AEGIS-256X4 223361.81 Mb/s AEGIS-256X4 223361.81 Mb/s
@@ -271,3 +318,7 @@ AEGIS-256 MAC 116776.62 Mb/s
AEGIS-256X2 MAC 224150.04 Mb/s AEGIS-256X2 MAC 224150.04 Mb/s
AEGIS-256X4 MAC 392088.05 Mb/s AEGIS-256X4 MAC 392088.05 Mb/s
``` ```
## Alternatives
There is also a package named [pyaegis](https://github.com/jedisct1/pyaegis) on PyPI that is unrelated to this module, but that also binds to the libaegis C library. There are also a number of modules named aegis from different packages not at all related to the encryption algorithm.
View File
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""
Python benchmark matching src/test/benchmark.zig for all supported Aegis algorithms.
It performs two benchmarks with the same parameters as the Zig version:
- AEGIS encrypt (attached tag, maclen = ABYTES_MIN)
- AEGIS MAC (clone state pattern)
Output format and throughput units mirror the Zig benchmark (Mb/s).
"""
import os
import time
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
MSG_LEN = 16384000 # 16 000 KiB
ITERATIONS = 100
ALGORITHMS = [
("AEGIS-128L", aegis128l),
("AEGIS-128X2", aegis128x2),
("AEGIS-128X4", aegis128x4),
("AEGIS-256", aegis256),
("AEGIS-256X2", aegis256x2),
("AEGIS-256X4", aegis256x4),
]
def _random_bytes(n: int) -> bytes:
return os.urandom(n)
def bench_encrypt(alg_name: str, a) -> None:
key = _random_bytes(a.KEYBYTES)
nonce = _random_bytes(a.NPUBBYTES)
# Single buffer, as in Zig: c_out == m buffer, with tag appended
maclen = a.ABYTES_MIN
buf = bytearray(MSG_LEN + maclen)
# Initialize buffer with random data
buf[:] = _random_bytes(len(buf))
mview = memoryview(buf)[:MSG_LEN]
t0 = time.perf_counter()
for _ in range(ITERATIONS):
a.encrypt(key, nonce, mview, None, maclen=maclen, into=buf)
t1 = time.perf_counter()
# Prevent any unrealistic optimization assumptions
_ = buf[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{alg_name}\t{throughput_mbps:10.2f} Mb/s")
def bench_mac(alg_name: str, a) -> None:
key = _random_bytes(a.KEYBYTES)
nonce = _random_bytes(a.NPUBBYTES)
buf = bytearray(MSG_LEN)
buf[:] = _random_bytes(len(buf))
mac0 = a.Mac(key, nonce)
mac_out = bytearray(a.ABYTES_MAX)
t0 = time.perf_counter()
for _ in range(ITERATIONS):
mac = mac0.clone()
mac.update(buf)
mac.final(maclen=a.ABYTES_MAX, into=mac_out)
t1 = time.perf_counter()
_ = mac_out[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{alg_name} MAC\t{throughput_mbps:10.2f} Mb/s")
if __name__ == "__main__":
# aegis_init() is called in the loader at import time already
# Run encrypt benchmarks in order: 256, 256x2, 256x4, 128l, 128x2, 128x4
bench_encrypt("AEGIS-256", aegis256)
bench_encrypt("AEGIS-256X2", aegis256x2)
bench_encrypt("AEGIS-256X4", aegis256x4)
bench_encrypt("AEGIS-128L", aegis128l)
bench_encrypt("AEGIS-128X2", aegis128x2)
bench_encrypt("AEGIS-128X4", aegis128x4)
# Run MAC benchmarks in order: 128l, 128x2, 128x4, 256, 256x2, 256x4
bench_mac("AEGIS-128L", aegis128l)
bench_mac("AEGIS-128X2", aegis128x2)
bench_mac("AEGIS-128X4", aegis128x4)
bench_mac("AEGIS-256", aegis256)
bench_mac("AEGIS-256X2", aegis256x2)
bench_mac("AEGIS-256X4", aegis256x4)
-76
View File
@@ -1,76 +0,0 @@
"""Utility helpers for pyaegis.
Currently provides Python-side aligned allocation helpers that avoid relying
on libc/posix_memalign. Memory is owned by Python; C code only borrows it.
"""
from __future__ import annotations
from typing import Protocol
from ._loader import ffi
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
try:
from collections.abc import Buffer as _Buffer
class Buffer(_Buffer, Protocol): # type: ignore[misc]
def __len__(self) -> int: ...
except ImportError:
class Buffer(Protocol):
def __len__(self) -> int: ...
def __buffer__(self, flags: int) -> memoryview: ...
def aligned_address(obj) -> int:
"""Return the integer address of the start of a cffi array object."""
return int(ffi.cast("uintptr_t", ffi.addressof(obj, 0)))
def new_aligned_struct(ctype: str, alignment: int) -> tuple[object, object]:
"""Allocate memory for one instance of ``ctype`` with requested alignment.
This allocates a Python-owned unsigned char[] buffer large enough to find
an aligned start address. Returns (ptr, owner) where ptr is a ``ctype *``
and owner is the buffer object keeping the memory alive.
"""
if alignment & (alignment - 1): # Not power of two
raise ValueError("alignment must be a power of two")
size = ffi.sizeof(ctype)
base = ffi.new("unsigned char[]", size + alignment - 1)
addr = aligned_address(base)
offset = (-addr) & (alignment - 1)
aligned_uc = ffi.addressof(base, offset)
ptr = ffi.cast(f"{ctype} *", aligned_uc)
return ptr, base
def nonce_increment(nonce: Buffer) -> None:
"""Increment the nonce in place using little-endian byte order.
Useful for generating unique nonces for each consecutive message.
Args:
nonce: The nonce buffer to increment (modified in place).
"""
n = memoryview(nonce)
for i in range(len(n)):
if n[i] < 255:
n[i] += 1
return
n[i] = 0
def wipe(buffer: Buffer) -> None:
"""Set all bytes of the input buffer to zero.
Useful for securely clearing sensitive data from memory.
Args:
buffer: The buffer to wipe (modified in place).
"""
n = memoryview(buffer)
for i in range(len(n)):
n[i] = 0
+15 -9
View File
@@ -1,16 +1,15 @@
[build-system] [build-system]
requires = ["setuptools>=61.0", "cffi>=2.0.0"] requires = ["setuptools>=61.0", "cffi>=2.0.0", "setuptools-scm>=8.0"]
build-backend = "build_backend" build-backend = "build_backend"
backend-path = ["tools"] backend-path = ["tools"]
[project] [project]
name = "pyaegis" name = "aeg"
version = "0.2.0" dynamic = ["version"]
description = "Python bindings for libaegis" description = "AEGIS encryption easy to use Python binding. Wheels for major platforms."
readme = {file = "README.md", content-type = "text/markdown"}
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Topic :: Security :: Cryptography", "Topic :: Security :: Cryptography",
@@ -20,16 +19,23 @@ dependencies = [
] ]
[project.urls] [project.urls]
Homepage = "https://github.com/aegis-aead/libaegis" Homepage = "https://git.zi.fi/LeoVasanko/aegis-python"
Repository = "https://github.com/LeoVasanko/aegis-python"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"auditwheel>=6.5.0",
"pytest>=8.4.2", "pytest>=8.4.2",
"ruff>=0.14.4",
"setuptools>=80.9.0", "setuptools>=80.9.0",
"setuptools-scm>=9.2.2",
] ]
[tool.setuptools] [tool.setuptools]
packages = ["pyaegis"] package-dir = {"" = "src"}
packages = ["aeg"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pyaegis = ["*.h", "*.so", "*.pyd"] aeg = ["*.h", "*.so", "*.pyd"]
[tool.setuptools_scm]
+16 -30
View File
@@ -1,49 +1,35 @@
"""Setup script for pyaegis - builds CFFI extension linking to libaegis.a""" """Setup script for aeg - builds CFFI extension with libaegis C library."""
import sys
from pathlib import Path from pathlib import Path
from cffi import FFI from cffi import FFI
from setuptools import setup from setuptools import setup
# Locate the static library (built by build_backend.py before this runs)
lib_name = "aegis.lib" if sys.platform == "win32" else "libaegis.a"
libaegis_static = Path("libaegis/zig-out/lib") / lib_name
if not libaegis_static.exists():
raise RuntimeError(f"libaegis static library not found at {libaegis_static}")
libaegis_static = str(libaegis_static.resolve())
def find_libaegis(): # Include directory for headers
"""Locate libaegis.a - check common locations.""" libaegis_include = Path("libaegis/src/include")
libaegis_paths = [ if not libaegis_include.exists():
Path("libaegis/zig-out/lib/libaegis.a"), # Zig build output (repo build) raise RuntimeError(f"libaegis include directory not found at {libaegis_include}")
Path("libaegis/build/libaegis.a"), # CMake build output (repo build) include_dirs = [str(libaegis_include)]
Path("/usr/local/lib/libaegis.a"), # System install
Path("/usr/lib/libaegis.a"), # System install
]
for path in libaegis_paths:
if path.exists():
print(f"Found libaegis.a at: {path.resolve()}")
return str(path.resolve())
# Return None instead of raising - will be caught during build
return None
# Read the CDEF header # Read the CDEF header
cdef_path = Path(__file__).parent / "pyaegis" / "aegis_cdef.h" cdef_path = Path(__file__).parent / "src" / "aeg" / "aegis_cdef.h"
cdef_content = cdef_path.read_text(encoding="utf-8") cdef_content = cdef_path.read_text(encoding="utf-8")
# Create CFFI builder # Create CFFI builder
ffibuilder = FFI() ffibuilder = FFI()
ffibuilder.cdef(cdef_content) ffibuilder.cdef(cdef_content)
# Include directory for headers
include_dirs = []
libaegis_include = Path("libaegis/src/include")
if libaegis_include.exists():
include_dirs.append(str(libaegis_include.resolve()))
# Try to find libaegis.a, but don't fail if not found (build backend will build it)
libaegis_static = find_libaegis()
# Set the source # Set the source
ffibuilder.set_source( ffibuilder.set_source(
"pyaegis._aegis", # module name "aeg._aegis", # module name
""" """
#include "aegis.h" #include "aegis.h"
#include "aegis128l.h" #include "aegis128l.h"
@@ -54,7 +40,7 @@ ffibuilder.set_source(
#include "aegis256x4.h" #include "aegis256x4.h"
""", """,
include_dirs=include_dirs, include_dirs=include_dirs,
extra_objects=[libaegis_static] if libaegis_static else [], extra_objects=[libaegis_static],
) )
if __name__ == "__main__": if __name__ == "__main__":
+16
View File
@@ -0,0 +1,16 @@
import importlib
from ._ciphers import CIPHERS, CipherName
from ._typing import Cipher
__all__ = ["cipher", "CIPHERS", "Cipher", "CipherName"]
def cipher(alg: CipherName) -> Cipher:
"""Acquire a cipher module by name."""
name = alg.lower().replace("-", "")
if name == "aegis128":
name = "aegis128l" # AEGIS-128 is dead, the user meant AEGIS-128L
if name in CIPHERS.values():
return importlib.import_module(f".{name}", __package__) # type: ignore[return-value]
raise ValueError(f"Unknown algorithm {alg!r}. Valid options: {', '.join(CIPHERS)}")
+20
View File
@@ -0,0 +1,20 @@
# This file is generated by tools/generate.py. Do not edit.
from typing import Literal
CipherName = Literal[
"AEGIS-128L",
"AEGIS-128X2",
"AEGIS-128X4",
"AEGIS-256",
"AEGIS-256X2",
"AEGIS-256X4",
]
CIPHERS: dict[CipherName, str] = {
"AEGIS-128L": "aegis128l",
"AEGIS-128X2": "aegis128x2",
"AEGIS-128X4": "aegis128x4",
"AEGIS-256": "aegis256",
"AEGIS-256X2": "aegis256x2",
"AEGIS-256X4": "aegis256x4",
}
+1 -1
View File
@@ -1,6 +1,6 @@
"""Loader for libaegis CFFI extension module.""" """Loader for libaegis CFFI extension module."""
from pyaegis._aegis import ffi, lib from aeg._aegis import ffi, lib
__all__ = ["ffi", "lib"] __all__ = ["ffi", "lib"]
+122
View File
@@ -0,0 +1,122 @@
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from .util import Buffer
__all__ = ["Cipher"]
class _Mac(Protocol):
def reset(self) -> None: ...
def clone(self) -> "_Mac": ...
def update(self, data: "Buffer") -> None: ...
def final(self, into: "Buffer | None" = None) -> bytearray | memoryview: ...
def digest(self) -> bytes: ...
def hexdigest(self) -> str: ...
def verify(self, mac: "Buffer") -> None: ...
class _Encryptor(Protocol):
def update(
self, message: "Buffer", into: "Buffer | None" = None
) -> bytearray | memoryview: ...
def final(self, into: "Buffer | None" = None) -> bytearray | memoryview: ...
class _Decryptor(Protocol):
def update(
self, ct: "Buffer", into: "Buffer | None" = None
) -> bytearray | memoryview: ...
def final(self, mac: "Buffer") -> None: ...
class Cipher(Protocol):
NAME: str
KEYBYTES: int
NONCEBYTES: int
MACBYTES: int
MACBYTES_LONG: int
ALIGNMENT: int
RATE: int
Mac: type[_Mac]
Encryptor: type[_Encryptor]
Decryptor: type[_Decryptor]
@staticmethod
def random_key() -> bytearray: ...
@staticmethod
def random_nonce() -> bytearray: ...
@staticmethod
def encrypt_detached(
key: "Buffer",
nonce: "Buffer",
message: "Buffer",
ad: "Buffer | None" = None,
*,
maclen: int = ...,
ct_into: "Buffer | None" = None,
mac_into: "Buffer | None" = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]: ...
@staticmethod
def decrypt_detached(
key: "Buffer",
nonce: "Buffer",
ct: "Buffer",
mac: "Buffer",
ad: "Buffer | None" = None,
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def encrypt(
key: "Buffer",
nonce: "Buffer",
message: "Buffer",
ad: "Buffer | None" = None,
*,
maclen: int = ...,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def decrypt(
key: "Buffer",
nonce: "Buffer",
ct: "Buffer",
ad: "Buffer | None" = None,
*,
maclen: int = ...,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def stream(
key: "Buffer",
nonce: "Buffer | None",
length: int | None = None,
*,
into: "Buffer | None" = None,
) -> "bytearray | Buffer": ...
@staticmethod
def encrypt_unauthenticated(
key: "Buffer",
nonce: "Buffer",
message: "Buffer",
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def decrypt_unauthenticated(
key: "Buffer",
nonce: "Buffer",
ct: "Buffer",
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def mac(
key: "Buffer",
nonce: "Buffer",
data: "Buffer",
maclen: int = ...,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
/* This file is generated with tools/gen_cdef.py. Do not edit. */ /* This file is generated with tools/generate.py. Do not edit. */
typedef unsigned char uint8_t; typedef unsigned char uint8_t;
typedef unsigned long size_t; typedef unsigned long size_t;
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Python benchmark matching src/test/benchmark.zig for all supported Aegis algorithms.
It performs two benchmarks with the same parameters as the Zig version:
- AEGIS encrypt (attached tag, maclen = MACBYTES)
- AEGIS MAC (clone state pattern)
Output format and throughput units mirror the Zig benchmark (Mb/s).
"""
import secrets
import time
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
MSG_LEN = 16384000 # 16 000 KiB
ITERATIONS = 100
def bench_encrypt(ciph) -> None:
key = ciph.random_key()
nonce = ciph.random_nonce()
# Single buffer, as in Zig: c_out == m buffer, with tag appended
maclen = ciph.MACBYTES
buf = bytearray(MSG_LEN + maclen)
# Initialize buffer with random data
buf[:] = secrets.token_bytes(len(buf))
mview = memoryview(buf)[:MSG_LEN]
t0 = time.perf_counter()
for _ in range(ITERATIONS):
ciph.encrypt(key, nonce, mview, None, maclen=maclen, into=buf)
t1 = time.perf_counter()
# Prevent any unrealistic optimization assumptions
_ = buf[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{ciph.NAME}\t{throughput_mbps:10.2f} Mb/s")
def bench_mac(ciph) -> None:
key = ciph.random_key()
nonce = ciph.random_nonce()
buf = bytearray(MSG_LEN)
buf[:] = secrets.token_bytes(len(buf))
mac_out = bytearray(ciph.MACBYTES_LONG)
t0 = time.perf_counter()
for _ in range(ITERATIONS):
ciph.mac(key, nonce, buf, maclen=ciph.MACBYTES_LONG, into=mac_out)
t1 = time.perf_counter()
_ = mac_out[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{ciph.NAME} MAC\t{throughput_mbps:10.2f} Mb/s")
if __name__ == "__main__":
# aegis_init() is called in the loader at import time already
# Run encrypt benchmarks in order: 256, 256x2, 256x4, 128l, 128x2, 128x4
bench_encrypt(aegis256)
bench_encrypt(aegis256x2)
bench_encrypt(aegis256x4)
bench_encrypt(aegis128l)
bench_encrypt(aegis128x2)
bench_encrypt(aegis128x4)
# Run MAC benchmarks in order: 128l, 128x2, 128x4, 256, 256x2, 256x4
bench_mac(aegis128l)
bench_mac(aegis128x2)
bench_mac(aegis128x4)
bench_mac(aegis256)
bench_mac(aegis256x2)
bench_mac(aegis256x4)
+82
View File
@@ -0,0 +1,82 @@
"""Utility helpers for aeg.
Currently provides Python-side aligned allocation helpers that avoid relying
on libc/posix_memalign. Memory is owned by Python; C code only borrows it.
"""
from typing import Protocol
from ._loader import ffi
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
try:
from collections.abc import Buffer # type: ignore
except ImportError:
# Fallback for Python < 3.12
class Buffer(Protocol):
def __buffer__(self, flags: int) -> memoryview: ...
def aligned_address(obj) -> int:
"""Return the integer address of the start of a cffi array object."""
return int(ffi.cast("uintptr_t", ffi.addressof(obj, 0)))
class StructHolder:
"""Proxy object for aligned struct allocation.
Exposes the aligned pointer as a property and wipes the buffer on deletion.
"""
def __init__(self, ptr: object, view: memoryview):
self._ptr = ptr
self._view = view # Keep memoryview slice and its bytearray alive
@property
def ptr(self) -> object:
"""The aligned pointer to the struct."""
return self._ptr
def __del__(self):
wipe(self._view)
del self._ptr, self._view
def new_aligned_struct(ctype: str, alignment: int) -> StructHolder:
"""Allocate memory for one instance of ``ctype`` with requested alignment."""
# Allocate backing storage with extra space for alignment
size = ffi.sizeof(ctype)
view = memoryview(bytearray(size + alignment - 1))
# Compute alignment offset from the base address
offset = (-aligned_address(ffi.from_buffer(view))) & (alignment - 1)
# Slice the memoryview to the aligned region (keeps bytearray alive)
view = view[offset : offset + size]
return StructHolder(ffi.from_buffer(f"{ctype} *", view), view)
def nonce_increment(nonce: Buffer) -> None:
"""Increment the nonce in place using little-endian byte order.
Useful for generating unique nonces for each consecutive message.
Args:
nonce: The nonce buffer to increment (modified in place).
"""
n = memoryview(nonce)
for i in range(len(n)):
if n[i] < 255:
n[i] += 1
return
n[i] = 0
def wipe(buffer: Buffer) -> None:
"""Securely clearing sensitive data from memory. Sets all bytes of the buffer to 0xFF.
Args:
buffer: The buffer to wipe (modified in place).
"""
# This is the fastest method I have found in Python
n = memoryview(buffer).cast("B")
n[:] = b"\xff" * len(n)
-89
View File
@@ -1,89 +0,0 @@
#! /usr/bin/env python3
import json
import re
def tvdump(topic, tvs):
with open(filename(topic), "w") as f:
f.write(json.dumps(tvs, indent=2))
print(json.dumps(tvs, indent=2))
def filename(topic):
return re.sub(r"[^a-z0-9]+", "-", topic.lower()) + ".json"
header = True
in_tv = False
tv = {}
tvs = []
must_fail = False
with open("../draft-irtf-cfrg-aegis-aead.md") as f:
for line in f:
line = line.strip()
if line == "":
continue
if line.startswith("# Test Vectors"):
header = False
continue
if header:
continue
if line.startswith("## "):
if len(tvs) > 0:
tvdump(topic, tvs)
topic = line[3:]
tv_name = topic
tvs = []
continue
if line.startswith("### "):
tv_name = line[4:]
tv = {"test": tv_name}
in_tv = False
continue
if line == "~~~ test-vectors":
in_tv = True
tv = {"name": tv_name}
if must_fail:
tv["error"] = "verification failed"
must_fail = False
continue
if line == "~~~":
tvs.append(tv)
in_tv = False
current_key = None
continue
if line.find("verification failed") != -1:
must_fail = True
continue
if line == "After initialization:":
tv_name = tv_name + " (after initialization)"
if not in_tv:
continue
parts = line.split(":")
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip()
if key == "After Update":
continue
if key in tv:
key = key + "_2"
tv[key] = value
current_key = key
continue
if not current_key:
continue
tv[key] += line.strip()
tvdump(topic, tvs)
+9 -13
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4 from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from .util import random_split_bytes from .util import random_split_bytes
@@ -147,14 +147,12 @@ def test_encrypt_decrypt_incremental(vector):
expected_tag128 = bytes.fromhex(vector["tag128"]) expected_tag128 = bytes.fromhex(vector["tag128"])
# Incremental encryption with random chunking # Incremental encryption with random chunking
encryptor = alg.Encryptor(key, nonce, ad) encryptor = alg.Encryptor(key, nonce, ad, maclen=16)
ct_chunks = [] ct_chunks = []
for chunk in random_split_bytes(msg): for chunk in random_split_bytes(msg):
ct_result = encryptor.update(chunk) ct_result = encryptor.update(chunk)
ct_chunks.append(bytes(ct_result)) ct_chunks.append(bytes(ct_result))
final_output = encryptor.final(maclen=16) computed_mac = bytes(encryptor.final())
ct_chunks.append(bytes(final_output[:-16])) # ciphertext part
computed_mac = bytes(final_output[-16:]) # MAC part
# Combine ciphertext chunks # Combine ciphertext chunks
computed_ct = b"".join(ct_chunks) computed_ct = b"".join(ct_chunks)
@@ -170,7 +168,7 @@ def test_encrypt_decrypt_incremental(vector):
) )
# Incremental decryption with different random chunking # Incremental decryption with different random chunking
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
pt_chunks = [] pt_chunks = []
for chunk in random_split_bytes(computed_ct): for chunk in random_split_bytes(computed_ct):
pt_chunks.append(bytes(decryptor.update(chunk))) pt_chunks.append(bytes(decryptor.update(chunk)))
@@ -187,14 +185,12 @@ def test_encrypt_decrypt_incremental(vector):
expected_tag256 = bytes.fromhex(vector["tag256"]) expected_tag256 = bytes.fromhex(vector["tag256"])
# Incremental encryption with random chunking # Incremental encryption with random chunking
encryptor = alg.Encryptor(key, nonce, ad) encryptor = alg.Encryptor(key, nonce, ad, maclen=32)
ct_chunks = [] ct_chunks = []
for chunk in random_split_bytes(msg): for chunk in random_split_bytes(msg):
ct_result = encryptor.update(chunk) ct_result = encryptor.update(chunk)
ct_chunks.append(bytes(ct_result)) ct_chunks.append(bytes(ct_result))
final_output = encryptor.final(maclen=32) computed_mac = bytes(encryptor.final())
ct_chunks.append(bytes(final_output[:-32])) # ciphertext part
computed_mac = bytes(final_output[-32:]) # MAC part
# Combine ciphertext chunks # Combine ciphertext chunks
computed_ct = b"".join(ct_chunks) computed_ct = b"".join(ct_chunks)
@@ -210,7 +206,7 @@ def test_encrypt_decrypt_incremental(vector):
) )
# Incremental decryption with different random chunking # Incremental decryption with different random chunking
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
pt_chunks = [] pt_chunks = []
for chunk in random_split_bytes(computed_ct): for chunk in random_split_bytes(computed_ct):
pt_chunks.append(bytes(decryptor.update(chunk))) pt_chunks.append(bytes(decryptor.update(chunk)))
@@ -229,14 +225,14 @@ def test_encrypt_decrypt_incremental(vector):
# Test that incremental decryption fails with the provided (invalid) MACs # Test that incremental decryption fails with the provided (invalid) MACs
if "tag128" in vector: if "tag128" in vector:
invalid_mac = bytes.fromhex(vector["tag128"]) invalid_mac = bytes.fromhex(vector["tag128"])
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
decryptor.update(ct) # This should succeed decryptor.update(ct) # This should succeed
with pytest.raises(ValueError, match="authentication failed"): with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(invalid_mac) decryptor.final(invalid_mac)
if "tag256" in vector: if "tag256" in vector:
invalid_mac = bytes.fromhex(vector["tag256"]) invalid_mac = bytes.fromhex(vector["tag256"])
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
decryptor.update(ct) # This should succeed decryptor.update(ct) # This should succeed
with pytest.raises(ValueError, match="authentication failed"): with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(invalid_mac) decryptor.final(invalid_mac)
+173 -5
View File
@@ -2,10 +2,14 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from .util import random_split_bytes from .util import random_split_bytes
# All AEGIS algorithm modules
ALL_ALGORITHMS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
def load_mac_test_vectors(): def load_mac_test_vectors():
"""Load MAC test vectors from JSON file.""" """Load MAC test vectors from JSON file."""
@@ -81,10 +85,10 @@ def test_mac_class(vector):
# Test 128-bit MAC if present # Test 128-bit MAC if present
if "tag128" in vector: if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"]) expected_tag128 = bytes.fromhex(vector["tag128"])
mac_state = alg.Mac(key, nonce) mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data): for chunk in random_split_bytes(data):
mac_state.update(chunk) mac_state.update(chunk)
computed_tag128 = mac_state.final(maclen=16) computed_tag128 = mac_state.final()
assert computed_tag128 == expected_tag128, ( assert computed_tag128 == expected_tag128, (
f"128-bit MAC mismatch for {vector['name']}" f"128-bit MAC mismatch for {vector['name']}"
) )
@@ -92,10 +96,174 @@ def test_mac_class(vector):
# Test 256-bit MAC if present # Test 256-bit MAC if present
if "tag256" in vector: if "tag256" in vector:
expected_tag256 = bytes.fromhex(vector["tag256"]) expected_tag256 = bytes.fromhex(vector["tag256"])
mac_state = alg.Mac(key, nonce) mac_state = alg.Mac(key, nonce, maclen=32)
for chunk in random_split_bytes(data): for chunk in random_split_bytes(data):
mac_state.update(chunk) mac_state.update(chunk)
computed_tag256 = mac_state.final(maclen=32) computed_tag256 = mac_state.final()
assert computed_tag256 == expected_tag256, ( assert computed_tag256 == expected_tag256, (
f"256-bit MAC mismatch for {vector['name']}" f"256-bit MAC mismatch for {vector['name']}"
) )
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
def test_mac_class_with_digest(vector):
"""Test MAC computation using digest() and hexdigest() instead of final()."""
alg = get_algorithm_module(vector["name"])
key = bytes.fromhex(vector["key"])
nonce = bytes.fromhex(vector["nonce"])
data = bytes.fromhex(vector["data"])
# Test 128-bit MAC if present
if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"])
# Test with digest()
mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
computed_tag128 = mac_state.digest()
assert computed_tag128 == expected_tag128, (
f"128-bit MAC mismatch for {vector['name']} using digest()"
)
# Test that digest() can be called multiple times
computed_tag128_again = mac_state.digest()
assert computed_tag128 == computed_tag128_again, (
"digest() should return the same value on repeated calls"
)
# Test hexdigest()
mac_state2 = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state2.update(chunk)
hex_tag = mac_state2.hexdigest()
assert hex_tag == expected_tag128.hex(), (
f"128-bit MAC hexdigest mismatch for {vector['name']}"
)
# Test that hexdigest() can be called multiple times
hex_tag_again = mac_state2.hexdigest()
assert hex_tag == hex_tag_again, (
"hexdigest() should return the same value on repeated calls"
)
# Test 256-bit MAC if present
if "tag256" in vector:
expected_tag256 = bytes.fromhex(vector["tag256"])
# Test with digest()
mac_state = alg.Mac(key, nonce, maclen=32)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
computed_tag256 = mac_state.digest()
assert computed_tag256 == expected_tag256, (
f"256-bit MAC mismatch for {vector['name']} using digest()"
)
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
def test_mac_clone(vector):
"""Test that cloning a Mac state works correctly."""
alg = get_algorithm_module(vector["name"])
key = bytes.fromhex(vector["key"])
nonce = bytes.fromhex(vector["nonce"])
data = bytes.fromhex(vector["data"])
# Test 128-bit MAC if present
if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"])
mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
# Clone the state
cloned_state = mac_state.clone()
# Both should produce the same tag
tag1 = mac_state.final()
tag2 = cloned_state.final()
assert tag1 == expected_tag128
assert tag2 == expected_tag128
assert tag1 == tag2
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
def test_mac_reset(vector):
"""Test that resetting a Mac state works correctly."""
alg = get_algorithm_module(vector["name"])
key = bytes.fromhex(vector["key"])
nonce = bytes.fromhex(vector["nonce"])
data = bytes.fromhex(vector["data"])
# Test 128-bit MAC if present
if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"])
mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
tag1 = mac_state.final()
assert tag1 == expected_tag128
# Reset and compute again
mac_state.reset()
for chunk in random_split_bytes(data):
mac_state.update(chunk)
tag2 = mac_state.final()
assert tag2 == expected_tag128
assert tag1 == tag2
@pytest.mark.parametrize("alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1])
def test_mac_reset_after_digest(alg):
"""Test that reset() clears the cached digest and allows reuse."""
key = alg.random_key()
nonce = alg.random_nonce()
mac_state = alg.Mac(key, nonce)
mac_state.update(b"Hello, world!")
tag1 = mac_state.digest()
# After digest(), update should fail
with pytest.raises(RuntimeError):
mac_state.update(b"More data")
# Reset should clear the cached digest
mac_state.reset()
# Now we should be able to update again
mac_state.update(b"Different data")
tag2 = mac_state.digest()
# Tags should be different since we used different data
assert tag1 != tag2
@pytest.mark.parametrize("alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1])
def test_mac_clone_preserves_cached_digest(alg):
"""Test that cloning preserves the cached digest state."""
key = alg.random_key()
nonce = alg.random_nonce()
mac_state = alg.Mac(key, nonce)
mac_state.update(b"Hello, world!")
tag1 = mac_state.digest()
# Clone after digest
cloned_state = mac_state.clone()
# Both should return the same cached tag
tag2 = cloned_state.digest()
assert tag1 == tag2
# Both should be unable to update
with pytest.raises(RuntimeError):
mac_state.update(b"More data")
with pytest.raises(RuntimeError):
cloned_state.update(b"More data")
+338
View File
@@ -0,0 +1,338 @@
"""Tests for Encryptor and Decryptor finalization behavior.
This module verifies that Encryptor and Decryptor objects become unusable
after calling final(), preventing accidental misuse.
"""
import pytest
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
# All AEGIS algorithm modules
ALL_ALGORITHMS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
class TestMacFinalization:
"""Test that Mac becomes unusable after final()."""
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_update_after_final_raises(self, alg):
"""Test that calling update() after final() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.final()
# Attempting to update after final should raise RuntimeError
with pytest.raises(RuntimeError, match="Cannot update after final\\(\\)"):
mac.update(b"More data")
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_final_after_final_raises(self, alg):
"""Test that calling final() after final() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.final()
# Attempting to call final again should raise RuntimeError
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.final()
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_digest_after_final_raises(self, alg):
"""Test that digest() and hexdigest() raise after final()."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.final()
# digest() should raise after final()
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.digest()
# hexdigest() should also raise after final()
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.hexdigest()
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_update_after_digest_raises(self, alg):
"""Test that calling update() after digest() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.digest()
# Attempting to update after digest should raise RuntimeError
with pytest.raises(RuntimeError, match="Cannot update after final\\(\\)"):
mac.update(b"More data")
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_final_after_digest_raises(self, alg):
"""Test that calling final() after digest() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.digest()
# Attempting to call final after digest should raise RuntimeError
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.final()
class TestEncryptorFinalization:
"""Test that Encryptor becomes unusable after final()."""
def test_update_after_final_raises(self):
"""Test that calling update() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Encrypt some data and finalize
encryptor.update(b"Hello, world!")
encryptor.final()
# Attempting to update after final should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
encryptor.update(b"More data")
def test_final_after_final_raises(self):
"""Test that calling final() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Encrypt some data and finalize
encryptor.update(b"Hello, world!")
encryptor.final()
# Attempting to call final again should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
encryptor.final()
def test_update_then_final_after_final_raises(self):
"""Test that both update() and final() fail after final()."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Encrypt and finalize
encryptor.update(b"Test data")
encryptor.final()
# Both operations should fail
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
encryptor.update(b"More data")
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
encryptor.final()
def test_empty_encryption_finalization(self):
"""Test that finalization works correctly with no update() calls."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Finalize without any updates
tag = encryptor.final()
assert len(tag) == aegis256x4.MACBYTES
# Should still be unusable after
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
encryptor.update(b"Data")
class TestDecryptorFinalization:
"""Test that Decryptor becomes unusable after final()."""
def test_update_after_final_raises(self):
"""Test that calling update() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Hello, world!"
# Encrypt first to get valid ciphertext and tag
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Now test decryption
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
decryptor.final(tag)
# Attempting to update after final should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
decryptor.update(b"More ciphertext")
def test_final_after_final_raises(self):
"""Test that calling final() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Hello, world!"
# Encrypt first to get valid ciphertext and tag
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Now test decryption
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
decryptor.final(tag)
# Attempting to call final again should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
decryptor.final(tag)
def test_update_then_final_after_final_raises(self):
"""Test that both update() and final() fail after final()."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Test data"
# Encrypt first
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Decrypt and finalize
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
decryptor.final(tag)
# Both operations should fail
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
decryptor.update(b"More ciphertext")
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
decryptor.final(tag)
def test_empty_decryption_finalization(self):
"""Test that finalization works correctly with no update() calls."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
# Encrypt empty message
ct, tag = aegis256x4.encrypt_detached(key, nonce, b"")
# Decrypt without any updates
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.final(tag) # Should work with empty ciphertext
# Should still be unusable after
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
decryptor.update(b"Data")
def test_failed_verification_still_finalizes(self):
"""Test that even if verification fails, the object becomes unusable."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Hello, world!"
# Encrypt first
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Decrypt but use wrong tag
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
# Try to finalize with invalid tag - should raise ValueError
bad_tag = bytes(len(tag)) # All zeros
with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(bad_tag)
# Object should NOT be finalized on failure - should still be usable
# This is a design decision: failed verification shouldn't lock the object
# Let's verify current behavior
try:
decryptor.update(b"test")
# If this doesn't raise, the object is still usable after failed verification
# This might be the desired behavior
except RuntimeError:
# If this raises, failed verification also finalizes the object
pass
class TestMultipleChunksBeforeFinalization:
"""Test that multiple update() calls work before final()."""
def test_encryptor_multiple_updates(self):
"""Test that Encryptor can handle multiple update() calls before final()."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Multiple updates
encryptor.update(b"Hello, ")
encryptor.update(b"world!")
encryptor.update(b" More data.")
# Should still work
tag = encryptor.final()
assert len(tag) == aegis256x4.MACBYTES
# Now unusable
with pytest.raises(RuntimeError):
encryptor.update(b"More")
def test_decryptor_multiple_updates(self):
"""Test that Decryptor can handle multiple update() calls before final()."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
# Encrypt in chunks
encryptor = aegis256x4.Encryptor(key, nonce)
ct1 = encryptor.update(b"Hello, ")
ct2 = encryptor.update(b"world!")
tag = encryptor.final()
# Decrypt in chunks
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct1)
decryptor.update(ct2)
decryptor.final(tag)
# Now unusable
with pytest.raises(RuntimeError):
decryptor.update(b"More")
+1 -1
View File
@@ -15,7 +15,7 @@ def _check_zig_available():
"\n" + "=" * 70 + "\n" "\n" + "=" * 70 + "\n"
"ERROR: Zig compiler not found!\n" "ERROR: Zig compiler not found!\n"
"\n" "\n"
"Building pyaegis requires the Zig compiler to build the libaegis\n" "Building aeg requires the Zig compiler to build the libaegis\n"
"static library. Please install Zig before building this package.\n" "static library. Please install Zig before building this package.\n"
"\n" "\n"
"Installation instructions:\n" "Installation instructions:\n"
-168
View File
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""Generate CFFI cdef string from libaegis headers.
This script parses the C header files and extracts function declarations,
typedefs, and struct definitions to generate the cdef() string needed by CFFI.
"""
import pathlib
import re
import sys
def preprocess_content(content: str) -> str:
"""Remove comments, preprocessor directives, and extern "C" blocks."""
# Remove multi-line comments
content = re.sub(r"/\*.*?\*/", " ", content, flags=re.DOTALL)
# Remove line comments
content = re.sub(r"//.*$", "", content, flags=re.MULTILINE)
# Remove preprocessor directives
content = re.sub(r"^\s*#.*$", "", content, flags=re.MULTILINE)
# Remove extern "C" blocks
content = re.sub(r'extern\s+"C"\s*\{', "", content)
content = re.sub(r"(?:^|\n)\s*\}\s*(?:\n|$)", "\n", content, flags=re.MULTILINE)
return content
def clean_declaration(text: str) -> str:
"""Clean up a C declaration for CFFI consumption."""
# Remove __attribute__(...) with proper nesting
while "__attribute__" in text:
old = text
text = re.sub(r"__attribute__\s*\(\([^()]*\)\)", "", text)
if text == old:
break
# For structs with CRYPTO_ALIGN, replace the field with "...;" to make it flexible
# This tells CFFI to use the C compiler's alignment instead of calculating it
if "CRYPTO_ALIGN" in text and "typedef struct" in text:
# Replace "CRYPTO_ALIGN(N) uint8_t opaque[SIZE];" with "...;"
text = re.sub(
r"CRYPTO_ALIGN\s*\(\s*\d+\s*\)\s+uint8_t\s+opaque\[\d+\];", "...;", text
)
else:
# For non-struct declarations, just remove CRYPTO_ALIGN
text = re.sub(r"CRYPTO_ALIGN\s*\(\s*\d+\s*\)", "", text)
# Normalize whitespace but preserve structure
lines = []
for line in text.split("\n"):
line = re.sub(r"\s+", " ", line).strip()
if line:
lines.append(line)
return " ".join(lines)
def extract_declarations(header_path: pathlib.Path) -> list[str]:
"""Extract function declarations and typedefs from a header file."""
content = header_path.read_text(encoding="utf-8")
content = preprocess_content(content)
declarations = []
# Extract typedefs (including structs)
typedef_pattern = r"typedef\s+struct\s+\w+\s*\{[^}]+\}\s*\w+\s*;"
for match in re.finditer(typedef_pattern, content, re.DOTALL):
decl = clean_declaration(match.group(0))
if decl:
declarations.append(decl)
# Extract function declarations - more permissive pattern
func_pattern = r"((?:const\s+)?(?:int|void|size_t)\s+\w+\s*\([^;]+?\)\s*;)"
for match in re.finditer(func_pattern, content, re.DOTALL):
decl = clean_declaration(match.group(0))
if decl and "aegis" in decl.lower():
declarations.append(decl)
return declarations
def format_declaration(decl: str, max_width: int = 100) -> str:
"""Format a declaration for readability, with intelligent line breaking."""
# If it's short enough, return as-is
if len(decl) <= max_width:
return decl
# For function declarations, try to break at parameter boundaries
if "(" in decl and ")" in decl:
# Find the function name and opening paren
match = re.match(r"(.*?\s+\w+\s*)\((.*)\)(.*)", decl)
if match:
prefix, params, suffix = match.groups()
# Break parameters if they're too long
if len(prefix) + len(params) + 2 > max_width:
# Split parameters
param_list = [p.strip() for p in params.split(",")]
if len(param_list) > 1:
formatted_params = (",\n" + " " * (len(prefix) + 1)).join(
param_list
)
return f"{prefix}({formatted_params}){suffix}"
return decl
def generate_cdef(include_dir: pathlib.Path) -> str:
"""Generate the complete CFFI cdef string from all aegis headers."""
lines = [
"/* This file is generated with tools/gen_cdef.py. Do not edit. */",
"",
"typedef unsigned char uint8_t;",
"typedef unsigned long size_t;",
"",
]
# Header files in order, skipping aegis.h as it might be included elsewhere
headers = [
"aegis.h",
"aegis128l.h",
"aegis128x2.h",
"aegis128x4.h",
"aegis256.h",
"aegis256x2.h",
"aegis256x4.h",
]
for header_name in headers:
header_path = include_dir / header_name
if not header_path.exists():
print(f"Warning: {header_name} not found", file=sys.stderr)
continue
lines.append(f"/* {header_name} */")
declarations = extract_declarations(header_path)
for decl in declarations:
formatted = format_declaration(decl)
lines.append(formatted)
lines.append("")
return "\n".join(lines)
def main() -> int:
# Find the include directory
root = pathlib.Path(__file__).parent.parent
include_dir = root / "libaegis" / "src" / "include"
if not include_dir.exists():
print(f"Include directory not found: {include_dir}", file=sys.stderr)
return 1
cdef_string = generate_cdef(include_dir)
# Write to a file in the pyaegis directory
output_dir = root / "pyaegis"
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "aegis_cdef.h"
output_path.write_text(cdef_string, encoding="utf-8")
print(f"Generated: {output_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""
Regenerate aegis*.py modules from the canonical template aegis256x4.py.
Changes per variant:
- Replace module name (aegis256x4 -> target)
- Replace label (AEGIS-256X4 -> target label like AEGIS-128L)
- Replace only the ALIGNMENT = <int> value
- Replace only the RATE = <int> value
We do not touch alloc_aligned(...) calls or any code formatting. Blank lines
after ALIGNMENT are preserved.
"""
import pathlib
import re
import sys
# Template and target locations
ROOT = pathlib.Path(__file__).parent.parent
AEGIS_DIR = ROOT / "pyaegis"
TEMPLATE = AEGIS_DIR / "aegis256x4.py"
# Variants to generate (template excluded) and their ALIGNMENT values
VARIANT_ALIGN = {
"aegis256": 16,
"aegis256x2": 32,
"aegis256x4": 64,
"aegis128l": 32,
"aegis128x2": 64,
"aegis128x4": 64,
}
# Variants and their RATE values
VARIANT_RATE = {
"aegis256": 16,
"aegis256x2": 32,
"aegis256x4": 64,
"aegis128l": 32,
"aegis128x2": 64,
"aegis128x4": 128,
}
TEMPLATE_NAME = "aegis256x4"
TEMPLATE_LABEL = "AEGIS-256X4"
ALIGNMENT_LINE_RE = re.compile(r"^(ALIGNMENT\s*=\s*)(\d+)(\s*)$", re.MULTILINE)
RATE_LINE_RE = re.compile(r"^(RATE\s*=\s*)(\d+)(\s*)$", re.MULTILINE)
def set_alignment_only(text: str, value: int) -> str:
"""Replace only the numeric ALIGNMENT value, preserving surrounding whitespace and lines.
This preserves any empty lines following the ALIGNMENT assignment because
the line ending is not part of the match; we keep any trailing spaces too.
"""
def _sub(m: re.Match[str]) -> str:
prefix, _num, suffix = m.group(1), m.group(2), m.group(3)
return f"{prefix}{value}{suffix}"
return ALIGNMENT_LINE_RE.sub(_sub, text)
def set_rate_only(text: str, value: int) -> str:
"""Replace only the numeric RATE value, preserving surrounding whitespace and lines.
This preserves any empty lines following the RATE assignment because
the line ending is not part of the match; we keep any trailing spaces too.
"""
def _sub(m: re.Match[str]) -> str:
prefix, _num, suffix = m.group(1), m.group(2), m.group(3)
return f"{prefix}{value}{suffix}"
return RATE_LINE_RE.sub(_sub, text)
def algo_label(name: str) -> str:
"""Return the canonical label like AEGIS-256X4 for a module name like aegis256x4."""
if not name.startswith("aegis"):
raise ValueError(f"Unexpected algorithm name: {name}")
return "AEGIS-" + name[5:].upper()
def generate_variant(template_src: str, variant: str) -> str:
# 1) replace lowercase template name
s = template_src.replace(TEMPLATE_NAME, variant)
# 2) replace uppercase label
s = s.replace(TEMPLATE_LABEL, algo_label(variant))
# 3) set ALIGNMENT constant value using fallback map
align_value = VARIANT_ALIGN.get(variant, 64)
s = set_alignment_only(s, align_value)
# 4) set RATE constant value using fallback map
rate_value = VARIANT_RATE.get(variant, 64)
s = set_rate_only(s, rate_value)
return s
def main() -> int:
if not TEMPLATE.exists():
print(f"Template not found: {TEMPLATE}", file=sys.stderr)
return 2
template_src = TEMPLATE.read_text(encoding="utf-8")
# Safety: ensure we are working from an up-to-date template that contains expected tokens
if TEMPLATE_NAME not in template_src or TEMPLATE_LABEL not in template_src:
print(
"Template file does not contain expected identifiers; aborting.",
file=sys.stderr,
)
return 3
wrote = []
for variant in VARIANT_ALIGN.keys():
# Skip the template itself; recreate all other modules
if variant == TEMPLATE_NAME:
continue
dst = AEGIS_DIR / f"{variant}.py"
content = generate_variant(template_src, variant)
dst.write_text(content, encoding="utf-8")
wrote.append(dst.relative_to(ROOT))
print("Generated modules:")
for p in wrote:
print(" -", p)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env -S uv run
"""Generate CFFI cdef and Python modules from libaegis C sources."""
import pathlib
import re
import sys
from typing import Dict, Tuple
def preprocess_content(content: str) -> str:
content = re.sub(r"/\*.*?\*/", " ", content, flags=re.DOTALL)
content = re.sub(r"//.*$", "", content, flags=re.MULTILINE)
content = re.sub(r"^\s*#.*$", "", content, flags=re.MULTILINE)
content = re.sub(r'extern\s+"C"\s*\{', "", content)
content = re.sub(r"(?:^|\n)\s*\}\s*(?:\n|$)", "\n", content, flags=re.MULTILINE)
return content
def clean_declaration(text: str) -> str:
while "__attribute__" in text:
old = text
text = re.sub(r"__attribute__\s*\(\([^()]*\)\)", "", text)
if text == old:
break
if "CRYPTO_ALIGN" in text and "typedef struct" in text:
text = re.sub(
r"CRYPTO_ALIGN\s*\(\s*\d+\s*\)\s+uint8_t\s+opaque\[\d+\];", "...;", text
)
else:
text = re.sub(r"CRYPTO_ALIGN\s*\(\s*\d+\s*\)", "", text)
lines = [
re.sub(r"\s+", " ", line).strip() for line in text.split("\n") if line.strip()
]
return " ".join(lines)
def extract_declarations(header_path: pathlib.Path) -> list[str]:
content = preprocess_content(header_path.read_text(encoding="utf-8"))
declarations = []
typedef_pattern = r"typedef\s+struct\s+\w+\s*\{[^}]+\}\s*\w+\s*;"
for match in re.finditer(typedef_pattern, content, re.DOTALL):
if decl := clean_declaration(match.group(0)):
declarations.append(decl)
func_pattern = r"((?:const\s+)?(?:int|void|size_t)\s+\w+\s*\([^;]+?\)\s*;)"
for match in re.finditer(func_pattern, content, re.DOTALL):
if (decl := clean_declaration(match.group(0))) and "aegis" in decl.lower():
declarations.append(decl)
return declarations
def format_declaration(decl: str, max_width: int = 100) -> str:
if len(decl) <= max_width:
return decl
if "(" in decl and ")" in decl:
if match := re.match(r"(.*?\s+\w+\s*)\((.*)\)(.*)", decl):
prefix, params, suffix = match.groups()
if len(prefix) + len(params) + 2 > max_width:
param_list = [p.strip() for p in params.split(",")]
if len(param_list) > 1:
formatted_params = (",\n" + " " * (len(prefix) + 1)).join(
param_list
)
return f"{prefix}({formatted_params}){suffix}"
return decl
def generate_cdef(include_dir: pathlib.Path) -> str:
lines = [
"/* This file is generated with tools/generate.py. Do not edit. */",
"",
"typedef unsigned char uint8_t;",
"typedef unsigned long size_t;",
"",
]
headers = [
"aegis.h",
"aegis128l.h",
"aegis128x2.h",
"aegis128x4.h",
"aegis256.h",
"aegis256x2.h",
"aegis256x4.h",
]
for header_name in headers:
header_path = include_dir / header_name
if not header_path.exists():
print(f"Warning: {header_name} not found", file=sys.stderr)
continue
lines.append(f"/* {header_name} */")
for decl in extract_declarations(header_path):
lines.append(format_declaration(decl))
lines.append("")
return "\n".join(lines)
def extract_constants(
common_h_path: pathlib.Path, header_path: pathlib.Path
) -> Dict[str, int]:
"""Extract constants from common.h (ALIGNMENT, RATE) and main header (KEYBYTES, NPUBBYTES, ABYTES_*)."""
constants = {}
# Extract from common.h
common_content = common_h_path.read_text(encoding="utf-8")
align_match = re.search(
r"^\s*#define\s+ALIGNMENT\s+(\d+)", common_content, re.MULTILINE
)
rate_match = re.search(r"^\s*#define\s+RATE\s+(\d+)", common_content, re.MULTILINE)
if not align_match or not rate_match:
raise ValueError(
f"Could not extract ALIGNMENT and/or RATE from {common_h_path}"
)
constants["ALIGNMENT"] = int(align_match.group(1))
constants["RATE"] = int(rate_match.group(1))
# Extract from main header
header_content = header_path.read_text(encoding="utf-8")
variant = header_path.stem # e.g., "aegis256x4"
for const_name in ["KEYBYTES", "NPUBBYTES", "ABYTES_MIN", "ABYTES_MAX"]:
pattern = rf"^\s*#define\s+{variant}_{const_name}\s+(\d+)"
match = re.search(pattern, header_content, re.MULTILINE)
if not match:
raise ValueError(f"Could not extract {const_name} from {header_path}")
constants[const_name] = int(match.group(1))
return constants
def extract_all_constants(
libaegis_src_dir: pathlib.Path, include_dir: pathlib.Path
) -> Dict[str, Dict[str, int]]:
variants = [
"aegis128l",
"aegis128x2",
"aegis128x4",
"aegis256",
"aegis256x2",
"aegis256x4",
]
constants = {}
for variant in variants:
common_h = libaegis_src_dir / variant / f"{variant}_common.h"
header_h = include_dir / f"{variant}.h"
if not common_h.exists():
print(f"Warning: {common_h} not found, skipping {variant}", file=sys.stderr)
continue
if not header_h.exists():
print(f"Warning: {header_h} not found, skipping {variant}", file=sys.stderr)
continue
try:
constants[variant] = extract_constants(common_h, header_h)
except Exception as e:
print(f"Error extracting constants from {variant}: {e}", file=sys.stderr)
return constants
ALIGNMENT_RE = re.compile(r"^(ALIGNMENT\s*=\s*)(\d+)(\s*)$", re.MULTILINE)
RATE_RE = re.compile(r"^(RATE\s*=\s*)(\d+)(\s*)$", re.MULTILINE)
def replace_constant(pattern: re.Pattern, text: str, value: int) -> str:
return pattern.sub(lambda m: f"{m.group(1)}{value}{m.group(3)}", text)
def algo_label(name: str) -> str:
return "AEGIS-" + name[5:].upper()
def generate_variant(template_src: str, variant: str, constants: Dict[str, int]) -> str:
"""Generate a variant module from the template with substituted constants."""
s = template_src.replace("aegis256x4", variant).replace(
"AEGIS-256X4", algo_label(variant)
)
# Fix the comment to reference the template, not the variant itself
s = re.sub(
r"# All modules are generated from \w+\.py by tools/generate\.py!",
"# All modules are generated from aegis256x4.py by tools/generate.py!",
s,
)
s = replace_constant(ALIGNMENT_RE, s, constants["ALIGNMENT"])
s = replace_constant(RATE_RE, s, constants["RATE"])
# Replace the constant assignments
s = re.sub(r"KEYBYTES = \d+", f"KEYBYTES = {constants['KEYBYTES']}", s)
s = re.sub(
r"NONCEBYTES = \d+",
f"NONCEBYTES = {constants['NPUBBYTES']}",
s,
)
s = re.sub(
r"MACBYTES = \d+",
f"MACBYTES = {constants['ABYTES_MIN']}",
s,
)
s = re.sub(
r"MACBYTES_LONG = \d+",
f"MACBYTES_LONG = {constants['ABYTES_MAX']}",
s,
)
return s
def generate_python_modules(
template_path: pathlib.Path,
output_dir: pathlib.Path,
constants: Dict[str, Dict[str, int]],
) -> Tuple[list[pathlib.Path], list[pathlib.Path]]:
if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}")
template_src = template_path.read_text(encoding="utf-8")
if "aegis256x4" not in template_src or "AEGIS-256X4" not in template_src:
raise ValueError("Template file does not contain expected identifiers")
updated = []
unchanged = []
for variant, const_dict in constants.items():
dst = output_dir / f"{variant}.py"
if variant == "aegis256x4":
# Update template in place with its own constants
new_content = replace_constant(
ALIGNMENT_RE, template_src, const_dict["ALIGNMENT"]
)
new_content = replace_constant(RATE_RE, new_content, const_dict["RATE"])
# Replace the constant assignments for the template itself
new_content = re.sub(
r"KEYBYTES = \d+",
f"KEYBYTES = {const_dict['KEYBYTES']}",
new_content,
)
new_content = re.sub(
r"NONCEBYTES = \d+",
f"NONCEBYTES = {const_dict['NPUBBYTES']}",
new_content,
)
new_content = re.sub(
r"MACBYTES = \d+",
f"MACBYTES = {const_dict['ABYTES_MIN']}",
new_content,
)
new_content = re.sub(
r"MACBYTES_LONG = \d+",
f"MACBYTES_LONG = {const_dict['ABYTES_MAX']}",
new_content,
)
else:
new_content = generate_variant(template_src, variant, const_dict)
if dst.exists() and dst.read_text(encoding="utf-8") == new_content:
unchanged.append(dst)
else:
dst.write_bytes(new_content.encode())
updated.append(dst)
return updated, unchanged
def generate_ciphers_module(constants: Dict[str, Dict[str, int]]) -> str:
labels = [algo_label(variant) for variant in constants]
literal_items = ", ".join(f'"{label}"' for label in labels)
lines = [
"# This file is generated by tools/generate.py. Do not edit.",
"from typing import Literal",
"",
f"CipherName = Literal[{literal_items}]",
"",
"CIPHERS: dict[CipherName, str] = {",
]
for variant in constants:
lines.append(f' "{algo_label(variant)}": "{variant}",')
lines.append("}")
return "\n".join(lines) + "\n"
def main() -> int:
root = pathlib.Path(__file__).parent.parent
libaegis_src_dir = root / "libaegis" / "src"
include_dir = libaegis_src_dir / "include"
pyaegis_dir = root / "src" / "aeg"
if not include_dir.exists():
print(f"Include directory not found: {include_dir}", file=sys.stderr)
return 1
if not libaegis_src_dir.exists():
print(f"Source directory not found: {libaegis_src_dir}", file=sys.stderr)
return 1
print("Step 1: Extracting constants from C sources...", file=sys.stderr)
constants = extract_all_constants(libaegis_src_dir, include_dir)
if not constants:
print("Error: No constants extracted", file=sys.stderr)
return 1
print("Step 2: Generating CFFI cdef header...", file=sys.stderr)
pyaegis_dir.mkdir(exist_ok=True)
cdef_path = pyaegis_dir / "aegis_cdef.h"
cdef_content = generate_cdef(include_dir)
if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content:
print(f" - No changes to {cdef_path}", file=sys.stderr)
else:
cdef_path.write_bytes(cdef_content.encode())
print(f" - Updated {cdef_path}", file=sys.stderr)
print("Step 3: Generating _ciphers.py...", file=sys.stderr)
ciphers_path = pyaegis_dir / "_ciphers.py"
ciphers_content = generate_ciphers_module(constants)
if (
ciphers_path.exists()
and ciphers_path.read_text(encoding="utf-8") == ciphers_content
):
print(f" - No changes to {ciphers_path.name}", file=sys.stderr)
else:
ciphers_path.write_bytes(ciphers_content.encode())
print(f" - Updated {ciphers_path.name}", file=sys.stderr)
print("Step 4: Generating Python modules...", file=sys.stderr)
try:
updated, unchanged = generate_python_modules(
pyaegis_dir / "aegis256x4.py", pyaegis_dir, constants
)
if updated:
for p in updated:
print(f" - {p.relative_to(root)}", file=sys.stderr)
if unchanged:
print(
" - No changes to",
f"{len(unchanged)} modules"
if len(unchanged) > 1
else unchanged[0].name,
file=sys.stderr,
)
except Exception as e:
print(f"Error generating Python modules: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env -S uv run
"""Build wheels for all supported Python versions using uv."""
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from packaging.version import Version
# Import generate module from same directory
sys.path.insert(0, str(Path(__file__).parent))
import generate
PYTHON_VERSIONS = [
"3.10",
"3.11",
"3.12",
"3.13",
"3.14",
"3.14t",
"3.15",
"3.15t",
"pypy3.10",
"pypy3.11",
]
def get_version_from_scm():
"""Get version from setuptools-scm (git tags)."""
try:
result = subprocess.run(
["uv", "run", "-m", "setuptools_scm"],
capture_output=True,
text=True,
check=True,
cwd=Path(__file__).parent.parent,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"✗ Error getting version from setuptools-scm: {e}", file=sys.stderr)
return None
def is_release_version(version):
"""Check if version is a clean release (no dev/post/local identifiers)."""
# A release version is just x.y.z with optional alpha/beta/rc suffixes
# No +local or .devN or .postN
if not version:
return False
return not any(marker in version for marker in ["+", ".dev", ".post"])
def get_next_version(current_version):
"""Get the next release version from a dev version."""
# Parse base version (strips dev/local parts)
try:
v = Version(current_version)
return f"{v.major}.{v.minor}.{v.micro}"
except Exception:
return current_version
def is_working_copy_clean():
"""Check if git working copy is clean."""
result = subprocess.run(
["git", "status", "--porcelain"], capture_output=True, text=True
)
return result.returncode == 0 and not result.stdout.strip()
def make_release_message(version):
"""Generate message for making a release."""
next_version = get_next_version(version)
is_clean = is_working_copy_clean()
msg = "\n⚠️ This is not a clean release version; upload to PyPI skipped.\n\n"
msg += f"To create a release (e.g. {next_version}) and upload to PyPI:\n"
if not is_clean:
msg += " 1. Add and commit changes on the working copy\n"
msg += f" 2. Tag the commit: git tag v{next_version}\n"
msg += " 3. Run this script again\n"
msg += f" 4. Push the tag: git push origin v{next_version}\n"
else:
msg += f" 1. Tag the current commit: git tag v{next_version}\n"
msg += " 2. Run this script again\n"
msg += f" 3. Push the tag: git push origin v{next_version}\n"
msg += (
f"\nIf the build didn't work, delete the tag with git tag -d v{next_version}\n"
)
return msg
def run_command(cmd, description):
"""Run a command and handle errors."""
print(f"\n{'=' * 70}")
print(f"{description}")
print(f"{'=' * 70}")
print(f">>> {' '.join(cmd)}")
try:
subprocess.run(cmd, check=True)
print(f"{description} completed successfully")
return True
except subprocess.CalledProcessError as e:
print(f"{description} failed with exit code {e.returncode}", file=sys.stderr)
return False
def normalize_line_endings(repo_root: Path):
"""Normalize all text files to LF line endings."""
# Patterns for files to normalize
patterns = [
"src/aeg/**/*.py",
"src/aeg/**/*.h",
"tests/**/*.py",
"tools/**/*.py",
"*.py",
"*.md",
"*.txt",
"*.toml",
"*.in",
]
for pattern in patterns:
for file_path in repo_root.glob(pattern):
if file_path.is_file():
content = file_path.read_bytes()
if b"\r\n" in content:
content = content.replace(b"\r\n", b"\n")
file_path.write_bytes(content)
def main():
"""Build wheels for all supported Python versions."""
repo_root = Path(__file__).parent.parent
dist_dir = repo_root / "dist"
# Generate CFFI definitions and Python modules
print(f"\n{'=' * 70}")
print("Code generation from C headers (tools/generate.py)")
print(f"{'=' * 70}")
if generate.main() != 0:
print("✗ Code generation failed", file=sys.stderr)
return 1
# Run ruff to check and fix any issues
if not run_command(
["uv", "run", "ruff", "check", "--fix", "."], "Running ruff check --fix"
):
print("✗ Ruff check failed", file=sys.stderr)
return 1
# Run ruff format
if not run_command(["uv", "run", "ruff", "format", "."], "Running ruff format"):
print("✗ Ruff format failed", file=sys.stderr)
return 1
# Normalize all line endings to LF (important for consistent builds)
normalize_line_endings(repo_root)
# Get version from git repo
version = get_version_from_scm()
if not version:
return 1
is_release = is_release_version(version)
# Main header for the packaging process
print(f"\n{'=' * 70}")
print(
f"Packaging aeg-{version}"
+ (" for release" if is_release else " (not release)")
)
print(f"Building wheels for Python versions: {', '.join(PYTHON_VERSIONS)}")
print(f"Output directory: {dist_dir}", end=" ")
# Clean dist directory
if dist_dir.exists():
print("(wiped)")
shutil.rmtree(dist_dir)
else:
print("(created)")
print(f"{'=' * 70}")
# Build source distribution first
if not run_command(
["uv", "build", "--sdist", "--quiet"], "Building source distribution"
):
print("✗ Source distribution build failed", file=sys.stderr)
return 1
failed_builds = []
successful_wheels = []
for py_version in PYTHON_VERSIONS:
# Build wheel
description = f"Building wheel for Python {py_version}"
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
if not run_command(cmd, description):
failed_builds.append(py_version)
continue
# Find the wheel for this version
if py_version.startswith("pypy"):
# PyPy wheels use pp3XX format
wheel_pattern = (
f"aeg-*-pp{py_version.replace('pypy', '').replace('.', '')}-*.whl"
)
else:
wheel_pattern = (
f"aeg-*-cp{py_version.replace('.', '').replace('t', '')}-*.whl"
)
wheels = list(dist_dir.glob(wheel_pattern))
if not wheels:
print(f"✗ Could not find wheel for Python {py_version}", file=sys.stderr)
failed_builds.append(py_version)
continue
wheel = wheels[0]
# Repair wheel with auditwheel for manylinux compatibility (Linux only)
if platform.system() == "Linux":
repair_cmd = [
"uv",
"run",
"auditwheel",
"repair",
str(wheel),
"-w",
str(dist_dir),
]
if not run_command(
repair_cmd, f"Repairing wheel for Python {py_version} with auditwheel"
):
print(
f"✗ Auditwheel repair failed for Python {py_version}",
file=sys.stderr,
)
failed_builds.append(py_version)
continue
# Find the repaired wheel (it will have a different name)
all_wheels = list(
dist_dir.glob(f"aeg-*-cp{py_version.replace('.', '')}-*.whl")
)
repaired_wheels = [w for w in all_wheels if "linux_x86_64" not in str(w)]
if not repaired_wheels:
print(
f"✗ Could not find repaired (manylinux) wheel for Python {py_version}",
file=sys.stderr,
)
failed_builds.append(py_version)
continue
wheel = repaired_wheels[0] # Use the repaired wheel for testing
# Remove the unrepaired linux_x86_64 wheels
for w in all_wheels:
if "linux_x86_64" in str(w):
w.unlink()
# Test the wheel with pytest (use --isolated to avoid .venv conflicts)
test_cmd = [
"uv",
"run",
"--isolated",
"--python",
py_version,
"--with",
str(wheel),
"--with",
"pytest",
"pytest",
]
if not run_command(
test_cmd, f"Testing wheel for Python {py_version} with pytest"
):
print(f"✗ Tests failed for Python {py_version}", file=sys.stderr)
failed_builds.append(py_version)
continue
# Run benchmark (use --isolated to avoid .venv conflicts)
bench_cmd = [
"uv",
"run",
"--isolated",
"--python",
py_version,
"--with",
str(wheel),
"-m",
"aeg.benchmark",
]
if not run_command(bench_cmd, f"Running benchmark for Python {py_version}"):
print(f"✗ Benchmark failed for Python {py_version}", file=sys.stderr)
failed_builds.append(py_version)
continue
successful_wheels.append(wheel)
# Summary
print(f"\n{'=' * 70}")
print("BUILD SUMMARY")
print(f"{'=' * 70}")
print(
f"Successful builds: sdist and {len(successful_wheels)}/{len(PYTHON_VERSIONS)} wheels"
)
if failed_builds:
print(f"\nFailed builds: {len(failed_builds)}")
for version in failed_builds:
print(f" ✗ Python {version}")
if not successful_wheels:
print("\n✗ No successful wheels to upload")
return 1
# List files to upload
sdist = list(dist_dir.glob("*.tar.gz"))
upload_files = sdist + successful_wheels
for file in upload_files:
print(f" - {file.name}")
# Only upload if this is a clean release version
if not is_release:
print(make_release_message(version))
return 0
# Upload with twine
upload_cmd = ["uvx", "twine", "upload"] + [str(f) for f in upload_files]
if not run_command(upload_cmd, "Uploading to PyPI with twine"):
print("\n✗ Upload failed")
return 1
print(f"\n{'=' * 70}")
print("All builds and upload completed successfully!")
print(f"{'=' * 70}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())