8 Commits
15 changed files with 392 additions and 196 deletions

No files matched your search

+2 -2
View File
@@ -4,8 +4,8 @@
/dist /dist
/build /build
/pyaegis/build /pyaegis/build
/pyaegis/_aegis.*.so /pyaegis/_aegis*.so
/pyaegis/_aegis.*.pyd /pyaegis/_aegis*.pyd
__pycache__ __pycache__
!.gitignore !.gitignore
!.gitmodules !.gitmodules
-2
View File
@@ -4,10 +4,8 @@ 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
+23 -29
View File
@@ -49,7 +49,7 @@ Common parameters and returns (applies to all items below):
- 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.
@@ -181,16 +181,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
# Encryption settings
from pyaegis import aegis128x4 as ciph from pyaegis import aegis128x4 as ciph
message = bytearray(30 * b"Attack at dawn! ")
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms 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 framebytes = 80 # In real applications 1 MiB or more is practical
maclen = ciph.MACBYTES # 16 maclen = ciph.MACBYTES # 16
message = bytearray(30 * b"Attack at dawn! ")
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)]
@@ -200,9 +201,8 @@ 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 pyaegis import aegis128x4 as ciph
key = b"sixteenbyte key!" key = b"sixteenbyte key!"
framebytes = 80 framebytes = 80
maclen = ciph.MACBYTES maclen = ciph.MACBYTES
@@ -239,7 +239,7 @@ Note: this is seekable by converting the block number to nonce with `idx.to_byte
### 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.
@@ -249,12 +249,12 @@ Foreign arrays can be used. This example fills a Numpy array with random integer
import numpy as np import numpy as np
from pyaegis import aegis128x4 as ciph from pyaegis import aegis128x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
arr = np.empty(10, dtype=np.uint64) # Uninitialised integer array arr = np.empty(10, dtype=np.uint64) # Uninitialised integer array
ciph.stream(key, nonce, into=arr) # Fill with random bytes ciph.stream(key, nonce, into=arr) # Fill with random bytes
print(arr) 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
@@ -276,28 +276,22 @@ 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 AMD hardware.
```fish ```fish
uv run -m pyaegis.benchmark $ uv run -m pyaegis.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:
+7 -5
View File
@@ -295,9 +295,7 @@ def decrypt(
out = bytearray(expected_out) out = bytearray(expected_out)
else: else:
if into.nbytes < expected_out: if into.nbytes < expected_out:
raise TypeError( raise TypeError("into length must be at least ct.nbytes - maclen")
"into length must be at least ct.nbytes - maclen"
)
out = into out = into
rc = _lib.aegis128l_decrypt( rc = _lib.aegis128l_decrypt(
@@ -582,7 +580,9 @@ class Mac:
out = into out = into
clone = self.clone() clone = self.clone()
rc = _lib.aegis128l_mac_final(clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes) rc = _lib.aegis128l_mac_final(
clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes
)
if rc != 0: if rc != 0:
err_num = ffi.errno err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
@@ -851,7 +851,9 @@ class Decryptor:
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}") raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0]) w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}" assert w == expected_out, (
f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}"
)
return out if into is None else memoryview(out)[:w] # type: ignore return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None: def final(self, mac: Buffer) -> None:
+7 -5
View File
@@ -295,9 +295,7 @@ def decrypt(
out = bytearray(expected_out) out = bytearray(expected_out)
else: else:
if into.nbytes < expected_out: if into.nbytes < expected_out:
raise TypeError( raise TypeError("into length must be at least ct.nbytes - maclen")
"into length must be at least ct.nbytes - maclen"
)
out = into out = into
rc = _lib.aegis128x2_decrypt( rc = _lib.aegis128x2_decrypt(
@@ -582,7 +580,9 @@ class Mac:
out = into out = into
clone = self.clone() clone = self.clone()
rc = _lib.aegis128x2_mac_final(clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes) rc = _lib.aegis128x2_mac_final(
clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes
)
if rc != 0: if rc != 0:
err_num = ffi.errno err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
@@ -851,7 +851,9 @@ class Decryptor:
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}") raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0]) w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}" assert w == expected_out, (
f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}"
)
return out if into is None else memoryview(out)[:w] # type: ignore return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None: def final(self, mac: Buffer) -> None:
+7 -5
View File
@@ -295,9 +295,7 @@ def decrypt(
out = bytearray(expected_out) out = bytearray(expected_out)
else: else:
if into.nbytes < expected_out: if into.nbytes < expected_out:
raise TypeError( raise TypeError("into length must be at least ct.nbytes - maclen")
"into length must be at least ct.nbytes - maclen"
)
out = into out = into
rc = _lib.aegis128x4_decrypt( rc = _lib.aegis128x4_decrypt(
@@ -582,7 +580,9 @@ class Mac:
out = into out = into
clone = self.clone() clone = self.clone()
rc = _lib.aegis128x4_mac_final(clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes) rc = _lib.aegis128x4_mac_final(
clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes
)
if rc != 0: if rc != 0:
err_num = ffi.errno err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
@@ -851,7 +851,9 @@ class Decryptor:
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}") raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0]) w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}" assert w == expected_out, (
f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}"
)
return out if into is None else memoryview(out)[:w] # type: ignore return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None: def final(self, mac: Buffer) -> None:
+7 -5
View File
@@ -295,9 +295,7 @@ def decrypt(
out = bytearray(expected_out) out = bytearray(expected_out)
else: else:
if into.nbytes < expected_out: if into.nbytes < expected_out:
raise TypeError( raise TypeError("into length must be at least ct.nbytes - maclen")
"into length must be at least ct.nbytes - maclen"
)
out = into out = into
rc = _lib.aegis256_decrypt( rc = _lib.aegis256_decrypt(
@@ -582,7 +580,9 @@ class Mac:
out = into out = into
clone = self.clone() clone = self.clone()
rc = _lib.aegis256_mac_final(clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes) rc = _lib.aegis256_mac_final(
clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes
)
if rc != 0: if rc != 0:
err_num = ffi.errno err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
@@ -851,7 +851,9 @@ class Decryptor:
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}") raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0]) w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}" assert w == expected_out, (
f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}"
)
return out if into is None else memoryview(out)[:w] # type: ignore return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None: def final(self, mac: Buffer) -> None:
+7 -5
View File
@@ -295,9 +295,7 @@ def decrypt(
out = bytearray(expected_out) out = bytearray(expected_out)
else: else:
if into.nbytes < expected_out: if into.nbytes < expected_out:
raise TypeError( raise TypeError("into length must be at least ct.nbytes - maclen")
"into length must be at least ct.nbytes - maclen"
)
out = into out = into
rc = _lib.aegis256x2_decrypt( rc = _lib.aegis256x2_decrypt(
@@ -582,7 +580,9 @@ class Mac:
out = into out = into
clone = self.clone() clone = self.clone()
rc = _lib.aegis256x2_mac_final(clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes) rc = _lib.aegis256x2_mac_final(
clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes
)
if rc != 0: if rc != 0:
err_num = ffi.errno err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
@@ -851,7 +851,9 @@ class Decryptor:
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}") raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0]) w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}" assert w == expected_out, (
f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}"
)
return out if into is None else memoryview(out)[:w] # type: ignore return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None: def final(self, mac: Buffer) -> None:
+7 -5
View File
@@ -295,9 +295,7 @@ def decrypt(
out = bytearray(expected_out) out = bytearray(expected_out)
else: else:
if into.nbytes < expected_out: if into.nbytes < expected_out:
raise TypeError( raise TypeError("into length must be at least ct.nbytes - maclen")
"into length must be at least ct.nbytes - maclen"
)
out = into out = into
rc = _lib.aegis256x4_decrypt( rc = _lib.aegis256x4_decrypt(
@@ -582,7 +580,9 @@ class Mac:
out = into out = into
clone = self.clone() clone = self.clone()
rc = _lib.aegis256x4_mac_final(clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes) rc = _lib.aegis256x4_mac_final(
clone._proxy.ptr, ffi.from_buffer(out), memoryview(out).nbytes
)
if rc != 0: if rc != 0:
err_num = ffi.errno err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
@@ -851,7 +851,9 @@ class Decryptor:
err_name = errno.errorcode.get(err_num, f"errno_{err_num}") err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}") raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0]) w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}" assert w == expected_out, (
f"got {w}, expected {expected_out}, ct.nbytes={ct.nbytes}"
)
return out if into is None else memoryview(out)[:w] # type: ignore return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None: def final(self, mac: Buffer) -> None:
+6 -11
View File
@@ -11,12 +11,9 @@ from ._loader import ffi
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"] __all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
try: try:
from collections.abc import Buffer as _Buffer # type: ignore[misc] from collections.abc import Buffer # type: ignore
class Buffer(_Buffer, Protocol): # type: ignore[misc]
pass
except ImportError: except ImportError:
# Fallback for Python < 3.12
class Buffer(Protocol): class Buffer(Protocol):
def __buffer__(self, flags: int) -> memoryview: ... def __buffer__(self, flags: int) -> memoryview: ...
@@ -75,13 +72,11 @@ def nonce_increment(nonce: Buffer) -> None:
def wipe(buffer: Buffer) -> None: def wipe(buffer: Buffer) -> None:
"""Set all bytes of the input buffer to zero. """Securely clearing sensitive data from memory. Sets all bytes of the buffer to 0xFF.
Useful for securely clearing sensitive data from memory.
Args: Args:
buffer: The buffer to wipe (modified in place). buffer: The buffer to wipe (modified in place).
""" """
n = memoryview(buffer) # This is the fastest method I have found in Python
for i in range(len(n)): n = memoryview(buffer).cast("B")
n[i] = 0 n[:] = b"\xff" * len(n)
+7 -3
View File
@@ -1,11 +1,11 @@
[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 = "pyaegis"
version = "0.3.0" dynamic = ["version"]
description = "Python bindings for libaegis" description = "Python bindings for libaegis"
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [
@@ -20,12 +20,14 @@ dependencies = [
] ]
[project.urls] [project.urls]
Homepage = "https://github.com/aegis-aead/libaegis" Homepage = "https://github.com/LeoVasanko/pyaegis"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"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]
@@ -33,3 +35,5 @@ packages = ["pyaegis"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pyaegis = ["*.h", "*.so", "*.pyd"] pyaegis = ["*.h", "*.so", "*.pyd"]
[tool.setuptools_scm]
+14 -28
View File
@@ -1,28 +1,23 @@
"""Setup script for pyaegis - builds CFFI extension linking to libaegis.a""" """Setup script for pyaegis - 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 / "pyaegis" / "aegis_cdef.h"
@@ -32,15 +27,6 @@ cdef_content = cdef_path.read_text(encoding="utf-8")
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 "pyaegis._aegis", # module name
@@ -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__":
-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)
+2 -2
View File
@@ -267,7 +267,7 @@ def generate_python_modules(
if dst.exists() and dst.read_text(encoding="utf-8") == new_content: if dst.exists() and dst.read_text(encoding="utf-8") == new_content:
unchanged.append(dst) unchanged.append(dst)
else: else:
dst.write_text(new_content, encoding="utf-8") dst.write_bytes(new_content.encode())
updated.append(dst) updated.append(dst)
return updated, unchanged return updated, unchanged
@@ -301,7 +301,7 @@ def main() -> int:
if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content: if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content:
print(f" - No changes to {cdef_path}", file=sys.stderr) print(f" - No changes to {cdef_path}", file=sys.stderr)
else: else:
cdef_path.write_text(cdef_content, encoding="utf-8") cdef_path.write_bytes(cdef_content.encode())
print(f" - Updated {cdef_path}", file=sys.stderr) print(f" - Updated {cdef_path}", file=sys.stderr)
print("Step 3: Generating Python modules...", file=sys.stderr) print("Step 3: Generating Python modules...", file=sys.stderr)
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Build wheels for all supported Python versions using uv."""
import subprocess
import sys
import tomllib
from pathlib import Path
import shutil
from packaging.specifiers import SpecifierSet
from packaging.version import Version
# Import generate module from same directory
sys.path.insert(0, str(Path(__file__).parent))
import generate
def get_python_versions():
"""Get supported Python versions."""
pyproject_toml = Path(__file__).parent.parent / "pyproject.toml"
data = tomllib.loads(pyproject_toml.read_text(encoding="utf-8"))
spec = SpecifierSet(data["project"]["requires-python"])
# Generate versions that match the specifier (up to Python 3.14)
return [f"3.{minor}" for minor in range(10, 15) if f"3.{minor}" in spec]
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
PYTHON_VERSIONS = get_python_versions()
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 = [
"pyaegis/**/*.py",
"pyaegis/**/*.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 pyaegis-{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
wheel_pattern = f"pyaegis-*-cp{py_version.replace('.', '')}-*.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]
# 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",
"pyaegis.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())