Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88efc4cabc | ||
|
|
04b11e9925 | ||
|
|
d8a9a7ee9d | ||
|
|
f5430a6ad4 | ||
|
|
f84ef727d3 | ||
|
|
bb9d11842a | ||
|
|
20e0ed8c5f | ||
|
|
62fc8fa855 | ||
|
|
67c2958384 | ||
|
|
a6faaf9f62 | ||
|
|
75cbc76845 | ||
|
|
95563a43d1 | ||
|
|
e58990a1c2 | ||
|
|
13445887e9 | ||
|
|
751a929836 |
@@ -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
|
||||
|
||||
@@ -42,14 +42,14 @@ 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)
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
@@ -167,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.
|
||||
|
||||
```python
|
||||
# Encryption settings
|
||||
from pyaegis 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! ")
|
||||
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:
|
||||
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:
|
||||
chunk = message[:framebytes - maclen]
|
||||
del message[:len(chunk)]
|
||||
@@ -186,15 +201,14 @@ with open("encrypted.bin", "wb") as f:
|
||||
```
|
||||
|
||||
```python
|
||||
from pyaegis import aegis128x4 as ciph
|
||||
|
||||
# Decryption needs same values as encryption
|
||||
from pyaegis import aegis128x4 as ciph
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -231,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.
|
||||
|
||||
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
|
||||
uv run -m pyaegis.benchmark
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```fish
|
||||
$ python -m pyaegis.benchmark
|
||||
AEGIS-256 107666.56 Mb/s
|
||||
AEGIS-256X2 191314.53 Mb/s
|
||||
AEGIS-256X4 211537.44 Mb/s
|
||||
AEGIS-128L 159074.08 Mb/s
|
||||
AEGIS-128X2 307332.53 Mb/s
|
||||
AEGIS-128X4 230106.70 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
|
||||
$ uv run -m pyaegis.benchmark
|
||||
AEGIS-256 103166.24 Mb/s
|
||||
AEGIS-256X2 184225.50 Mb/s
|
||||
AEGIS-256X4 194018.26 Mb/s
|
||||
AEGIS-128L 161551.73 Mb/s
|
||||
AEGIS-128X2 281987.80 Mb/s
|
||||
AEGIS-128X4 217997.37 Mb/s
|
||||
AEGIS-128L MAC 188886.40 Mb/s
|
||||
AEGIS-128X2 MAC 306457.97 Mb/s
|
||||
AEGIS-128X4 MAC 299576.59 Mb/s
|
||||
AEGIS-256 MAC 100914.04 Mb/s
|
||||
AEGIS-256X2 MAC 190208.20 Mb/s
|
||||
AEGIS-256X4 MAC 315919.87 Mb/s
|
||||
```
|
||||
|
||||
The Python library performance is similar to that of the C library:
|
||||
|
||||
+361
-265
File diff suppressed because it is too large
Load Diff
+361
-265
File diff suppressed because it is too large
Load Diff
+361
-265
File diff suppressed because it is too large
Load Diff
+361
-265
File diff suppressed because it is too large
Load Diff
+361
-265
File diff suppressed because it is too large
Load Diff
+365
-267
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 long size_t;
|
||||
|
||||
+28
-44
@@ -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)
|
||||
|
||||
+33
-27
@@ -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,10 @@ 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: ...
|
||||
from collections.abc import Buffer # type: ignore
|
||||
except ImportError:
|
||||
|
||||
# Fallback for Python < 3.12
|
||||
class Buffer(Protocol):
|
||||
def __len__(self) -> int: ...
|
||||
def __buffer__(self, flags: int) -> memoryview: ...
|
||||
|
||||
|
||||
@@ -29,22 +23,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:
|
||||
@@ -64,13 +72,11 @@ def nonce_increment(nonce: Buffer) -> None:
|
||||
|
||||
|
||||
def wipe(buffer: Buffer) -> None:
|
||||
"""Set all bytes of the input buffer to zero.
|
||||
|
||||
Useful for securely clearing sensitive data from memory.
|
||||
"""Securely clearing sensitive data from memory. Sets all bytes of the buffer to 0xFF.
|
||||
|
||||
Args:
|
||||
buffer: The buffer to wipe (modified in place).
|
||||
"""
|
||||
n = memoryview(buffer)
|
||||
for i in range(len(n)):
|
||||
n[i] = 0
|
||||
# This is the fastest method I have found in Python
|
||||
n = memoryview(buffer).cast("B")
|
||||
n[:] = b"\xff" * len(n)
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ backend-path = ["tools"]
|
||||
|
||||
[project]
|
||||
name = "pyaegis"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
description = "Python bindings for libaegis"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/aegis-aead/libaegis"
|
||||
Homepage = "https://github.com/LeoVasanko/pyaegis"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
+8
-12
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user