11 Commits
Author SHA1 Message Date
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
18 changed files with 3144 additions and 2000 deletions
+2 -8
View File
@@ -74,16 +74,10 @@ This creates files in the `dist/` directory.
## 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 `pyaegis/aegis256x4.py` or update libaegis headers, regenerate all files:
```fish
python tools/gen_modules.py
```
If you update libaegis headers, regenerate the CFFI definitions:
```fish
python tools/gen_cdef.py
python tools/generate.py
```
## Troubleshooting
+68 -23
View File
@@ -42,8 +42,8 @@ assert pt == msg
Common parameters and returns (applies to all items below):
- key: bytes of length a.KEYBYTES
- nonce: bytes of length a.NPUBBYTES (must be unique per (key, message))
- key: bytes of length ciph.KEYBYTES
- nonce: bytes of length ciph.NONCEBYTES (must be unique per message)
- message/ct: plain text or ciphertext
- ad: optional associated data (authenticated, not encrypted)
- into: optional output buffer (see below)
@@ -70,22 +70,30 @@ No MAC tag, vulnerable to alterations:
### Incremental AEAD
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
- final([into], maclen=16) -> mac_tag
- Decryptor(key, nonce, ad=None)
- final([into]) -> mac_tag
- Decryptor(key, nonce, ad=None, maclen=16)
- 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
No encryption, but prevents changes to the data without the correct key.
- mac(key, nonce, data, maclen=16, into=None) -> mac
- Mac(key, nonce)
- Mac(key, nonce, maclen=16)
- update(data)
- final(maclen=16[, into]) -> mac
- verify(mac) -> bool (True on success; raises ValueError on failure)
- final([into]) -> mac
- verify(mac) -> raises ValueError on failure
- digest() -> bytes
- hexdigest() -> 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
@@ -95,10 +103,10 @@ Useful for creating pseudo random bytes as rapidly as possible. Reuse of the sam
### 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_nonce() -> bytearray (length NPUBBYTES)
- random_nonce() -> bytearray (length NONCEBYTES)
- nonce_increment(nonce)
- wipe(buffer)
@@ -116,15 +124,21 @@ 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:
```python
from pyaegis 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)
print(mac)
print(mac.hex())
st = ciph.Mac(key, nonce)
st.update(b"message")
st.update(b"Mallory Says Hello!")
st.verify(mac) # Raises ValueError
# Alternative class-based API
a = ciph.Mac(key, nonce, maclen=32)
a.update(b"message")
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
@@ -151,12 +165,12 @@ Class-based interface for incremental updates is an alternative to the one-shot
from pyaegis import aegis256x4 as ciph
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")
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)
p2 = dec.update(c2)
dec.final(mac) # raises ValueError on failure
@@ -173,7 +187,7 @@ 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
maclen = ciph.MACBYTES # 16
with open("encrypted.bin", "wb") as f:
f.write(nonce) # Public initial nonce sent with the ciphertext
@@ -191,10 +205,10 @@ from pyaegis import aegis128x4 as ciph
# Decryption needs same values as encryption
key = b"sixteenbyte key!"
framebytes = 80
maclen = ciph.ABYTES_MIN
maclen = ciph.MACBYTES
with open("encrypted.bin", "rb") as f:
nonce = bytearray(f.read(ciph.NPUBBYTES))
nonce = bytearray(f.read(ciph.NONCEBYTES))
while True:
frame = f.read(framebytes)
if not frame:
@@ -204,12 +218,43 @@ with open("encrypted.bin", "rb") as f:
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 pyaegis 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=)
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.
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 pyaegis 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:
```python
+361 -265
View File
File diff suppressed because it is too large Load Diff
+361 -265
View File
File diff suppressed because it is too large Load Diff
+361 -265
View File
File diff suppressed because it is too large Load Diff
+361 -265
View File
File diff suppressed because it is too large Load Diff
+361 -265
View File
File diff suppressed because it is too large Load Diff
+361 -265
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 long size_t;
+28 -44
View File
@@ -3,13 +3,13 @@
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 encrypt (attached tag, maclen = MACBYTES)
- AEGIS MAC (clone state pattern)
Output format and throughput units mirror the Zig benchmark (Mb/s).
"""
import os
import secrets
import time
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
@@ -17,35 +17,22 @@ from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aeg
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)
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 = a.ABYTES_MIN
maclen = ciph.MACBYTES
buf = bytearray(MSG_LEN + maclen)
# Initialize buffer with random data
buf[:] = _random_bytes(len(buf))
buf[:] = secrets.token_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)
ciph.encrypt(key, nonce, mview, None, maclen=maclen, into=buf)
t1 = time.perf_counter()
# Prevent any unrealistic optimization assumptions
@@ -56,24 +43,21 @@ def bench_encrypt(alg_name: str, a) -> None:
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")
print(f"{ciph.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)
def bench_mac(ciph) -> None:
key = ciph.random_key()
nonce = ciph.random_nonce()
buf = bytearray(MSG_LEN)
buf[:] = _random_bytes(len(buf))
buf[:] = secrets.token_bytes(len(buf))
mac0 = a.Mac(key, nonce)
mac_out = bytearray(a.ABYTES_MAX)
mac_out = bytearray(ciph.MACBYTES_LONG)
t0 = time.perf_counter()
for _ in range(ITERATIONS):
mac = mac0.clone()
mac.update(buf)
mac.final(maclen=a.ABYTES_MAX, into=mac_out)
ciph.mac(key, nonce, buf, maclen=ciph.MACBYTES_LONG, into=mac_out)
t1 = time.perf_counter()
_ = mac_out[0]
@@ -83,23 +67,23 @@ def bench_mac(alg_name: str, a) -> None:
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")
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("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)
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("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)
bench_mac(aegis128l)
bench_mac(aegis128x2)
bench_mac(aegis128x4)
bench_mac(aegis256)
bench_mac(aegis256x2)
bench_mac(aegis256x4)
+29 -18
View File
@@ -4,8 +4,6 @@ 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
@@ -13,14 +11,13 @@ from ._loader import ffi
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
try:
from collections.abc import Buffer as _Buffer
from collections.abc import Buffer as _Buffer # type: ignore[misc]
class Buffer(_Buffer, Protocol): # type: ignore[misc]
def __len__(self) -> int: ...
pass
except ImportError:
class Buffer(Protocol):
def __len__(self) -> int: ...
def __buffer__(self, flags: int) -> memoryview: ...
@@ -29,22 +26,36 @@ def aligned_address(obj) -> int:
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.
class StructHolder:
"""Proxy object for aligned struct allocation.
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.
Exposes the aligned pointer as a property and wipes the buffer on deletion.
"""
if alignment & (alignment - 1): # Not power of two
raise ValueError("alignment must be a power of two")
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)
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
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:
+1 -1
View File
@@ -5,7 +5,7 @@ backend-path = ["tools"]
[project]
name = "pyaegis"
version = "0.2.0"
version = "0.3.0"
description = "Python bindings for libaegis"
requires-python = ">=3.10"
classifiers = [
+8 -12
View File
@@ -147,14 +147,12 @@ def test_encrypt_decrypt_incremental(vector):
expected_tag128 = bytes.fromhex(vector["tag128"])
# Incremental encryption with random chunking
encryptor = alg.Encryptor(key, nonce, ad)
encryptor = alg.Encryptor(key, nonce, ad, maclen=16)
ct_chunks = []
for chunk in random_split_bytes(msg):
ct_result = encryptor.update(chunk)
ct_chunks.append(bytes(ct_result))
final_output = encryptor.final(maclen=16)
ct_chunks.append(bytes(final_output[:-16])) # ciphertext part
computed_mac = bytes(final_output[-16:]) # MAC part
computed_mac = bytes(encryptor.final())
# Combine ciphertext chunks
computed_ct = b"".join(ct_chunks)
@@ -170,7 +168,7 @@ def test_encrypt_decrypt_incremental(vector):
)
# Incremental decryption with different random chunking
decryptor = alg.Decryptor(key, nonce, ad)
decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
pt_chunks = []
for chunk in random_split_bytes(computed_ct):
pt_chunks.append(bytes(decryptor.update(chunk)))
@@ -187,14 +185,12 @@ def test_encrypt_decrypt_incremental(vector):
expected_tag256 = bytes.fromhex(vector["tag256"])
# Incremental encryption with random chunking
encryptor = alg.Encryptor(key, nonce, ad)
encryptor = alg.Encryptor(key, nonce, ad, maclen=32)
ct_chunks = []
for chunk in random_split_bytes(msg):
ct_result = encryptor.update(chunk)
ct_chunks.append(bytes(ct_result))
final_output = encryptor.final(maclen=32)
ct_chunks.append(bytes(final_output[:-32])) # ciphertext part
computed_mac = bytes(final_output[-32:]) # MAC part
computed_mac = bytes(encryptor.final())
# Combine ciphertext chunks
computed_ct = b"".join(ct_chunks)
@@ -210,7 +206,7 @@ def test_encrypt_decrypt_incremental(vector):
)
# Incremental decryption with different random chunking
decryptor = alg.Decryptor(key, nonce, ad)
decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
pt_chunks = []
for chunk in random_split_bytes(computed_ct):
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
if "tag128" in vector:
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
with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(invalid_mac)
if "tag256" in vector:
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
with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(invalid_mac)
+172 -4
View File
@@ -2,10 +2,14 @@ import json
from pathlib import Path
import pytest
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from .util import random_split_bytes
# All AEGIS algorithm modules
ALL_ALGORITHMS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
def load_mac_test_vectors():
"""Load MAC test vectors from JSON file."""
@@ -81,10 +85,10 @@ def test_mac_class(vector):
# Test 128-bit MAC if present
if "tag128" in vector:
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):
mac_state.update(chunk)
computed_tag128 = mac_state.final(maclen=16)
computed_tag128 = mac_state.final()
assert computed_tag128 == expected_tag128, (
f"128-bit MAC mismatch for {vector['name']}"
)
@@ -92,10 +96,174 @@ def test_mac_class(vector):
# Test 256-bit MAC if present
if "tag256" in vector:
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):
mac_state.update(chunk)
computed_tag256 = mac_state.final(maclen=32)
computed_tag256 = mac_state.final()
assert computed_tag256 == expected_tag256, (
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 pyaegis 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")
-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())
+331
View File
@@ -0,0 +1,331 @@
"""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_text(new_content, encoding="utf-8")
updated.append(dst)
return updated, unchanged
def main() -> int:
root = pathlib.Path(__file__).parent.parent
libaegis_src_dir = root / "libaegis" / "src"
include_dir = libaegis_src_dir / "include"
pyaegis_dir = root / "pyaegis"
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_text(cdef_content, encoding="utf-8")
print(f" - Updated {cdef_path}", file=sys.stderr)
print("Step 3: 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())