Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84ef727d3 | ||
|
|
bb9d11842a | ||
|
|
20e0ed8c5f | ||
|
|
62fc8fa855 | ||
|
|
67c2958384 | ||
|
|
a6faaf9f62 | ||
|
|
75cbc76845 | ||
|
|
95563a43d1 | ||
|
|
e58990a1c2 | ||
|
|
13445887e9 | ||
|
|
751a929836 |
@@ -74,16 +74,10 @@ This creates files in the `dist/` directory.
|
|||||||
|
|
||||||
## Code Generation
|
## Code Generation
|
||||||
|
|
||||||
The Python modules are generated from templates. If you modify the core implementation in `pyaegis/aegis256x4.py`, regenerate the other variants:
|
The Python modules and CFFI definitions are generated from C sources and templates. If you modify the core implementation in `pyaegis/aegis256x4.py` or update libaegis headers, regenerate all files:
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
python tools/gen_modules.py
|
python tools/generate.py
|
||||||
```
|
|
||||||
|
|
||||||
If you update libaegis headers, regenerate the CFFI definitions:
|
|
||||||
|
|
||||||
```fish
|
|
||||||
python tools/gen_cdef.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ assert pt == msg
|
|||||||
|
|
||||||
Common parameters and returns (applies to all items below):
|
Common parameters and returns (applies to all items below):
|
||||||
|
|
||||||
- key: bytes of length a.KEYBYTES
|
- key: bytes of length ciph.KEYBYTES
|
||||||
- nonce: bytes of length a.NPUBBYTES (must be unique per (key, message))
|
- nonce: bytes of length ciph.NONCEBYTES (must be unique per message)
|
||||||
- message/ct: plain text or ciphertext
|
- message/ct: plain text or ciphertext
|
||||||
- ad: optional associated data (authenticated, not encrypted)
|
- ad: optional associated data (authenticated, not encrypted)
|
||||||
- into: optional output buffer (see below)
|
- into: optional output buffer (see below)
|
||||||
@@ -70,22 +70,30 @@ No MAC tag, vulnerable to alterations:
|
|||||||
### Incremental AEAD
|
### Incremental AEAD
|
||||||
|
|
||||||
Stateful classes that can be used for processing the data in separate chunks:
|
Stateful classes that can be used for processing the data in separate chunks:
|
||||||
- Encryptor(key, nonce, ad=None)
|
- Encryptor(key, nonce, ad=None, maclen=16)
|
||||||
- update(message[, into]) -> ciphertext_chunk
|
- update(message[, into]) -> ciphertext_chunk
|
||||||
- final([into], maclen=16) -> mac_tag
|
- final([into]) -> mac_tag
|
||||||
- Decryptor(key, nonce, ad=None)
|
- Decryptor(key, nonce, ad=None, maclen=16)
|
||||||
- update(ct_chunk[, into]) -> plaintext_chunk
|
- update(ct_chunk[, into]) -> plaintext_chunk
|
||||||
- final(mac) -> None (raises ValueError on failure)
|
- final(mac) -> raises ValueError on failure
|
||||||
|
|
||||||
|
The object releases its state and becomes unusable after final has been called.
|
||||||
|
|
||||||
### Message Authentication Code
|
### Message Authentication Code
|
||||||
|
|
||||||
No encryption, but prevents changes to the data without the correct key.
|
No encryption, but prevents changes to the data without the correct key.
|
||||||
|
|
||||||
- mac(key, nonce, data, maclen=16, into=None) -> mac
|
- mac(key, nonce, data, maclen=16, into=None) -> mac
|
||||||
- Mac(key, nonce)
|
- Mac(key, nonce, maclen=16)
|
||||||
- update(data)
|
- update(data)
|
||||||
- final(maclen=16[, into]) -> mac
|
- final([into]) -> mac
|
||||||
- verify(mac) -> bool (True on success; raises ValueError on failure)
|
- 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
|
### Keystream generation
|
||||||
|
|
||||||
@@ -95,10 +103,10 @@ Useful for creating pseudo random bytes as rapidly as possible. Reuse of the sam
|
|||||||
|
|
||||||
### Miscellaneous
|
### Miscellaneous
|
||||||
|
|
||||||
Constants (per module): KEYBYTES, NPUBBYTES, ABYTES_MIN, ABYTES_MAX, RATE, ALIGNMENT
|
Constants (per module): NAME, KEYBYTES, NONCEBYTES, MACBYTES, MACBYTES_LONG, RATE, ALIGNMENT
|
||||||
|
|
||||||
- random_key() -> bytearray (length KEYBYTES)
|
- random_key() -> bytearray (length KEYBYTES)
|
||||||
- random_nonce() -> bytearray (length NPUBBYTES)
|
- random_nonce() -> bytearray (length NONCEBYTES)
|
||||||
- nonce_increment(nonce)
|
- nonce_increment(nonce)
|
||||||
- wipe(buffer)
|
- wipe(buffer)
|
||||||
|
|
||||||
@@ -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:
|
A cryptographically secure keyed hash is produced. The example uses all zeroes for the nonce to always produce the same hash for the same key:
|
||||||
```python
|
```python
|
||||||
from pyaegis import aegis256x4 as ciph
|
from 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)
|
mac = ciph.mac(key, nonce, b"message", maclen=32)
|
||||||
print(mac)
|
print(mac.hex())
|
||||||
|
|
||||||
st = ciph.Mac(key, nonce)
|
# Alternative class-based API
|
||||||
st.update(b"message")
|
a = ciph.Mac(key, nonce, maclen=32)
|
||||||
st.update(b"Mallory Says Hello!")
|
a.update(b"message")
|
||||||
st.verify(mac) # Raises ValueError
|
print(a.hexdigest())
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
b = ciph.Mac(key, nonce, maclen=32)
|
||||||
|
b.update(b"message")
|
||||||
|
b.update(b"Mallory Says Hello!")
|
||||||
|
b.verify(mac) # Raises ValueError
|
||||||
```
|
```
|
||||||
|
|
||||||
### Detached mode encryption and decryption
|
### Detached mode encryption and decryption
|
||||||
@@ -151,12 +165,12 @@ Class-based interface for incremental updates is an alternative to the one-shot
|
|||||||
from pyaegis import aegis256x4 as ciph
|
from pyaegis import aegis256x4 as ciph
|
||||||
key, nonce = ciph.random_key(), ciph.random_nonce()
|
key, nonce = ciph.random_key(), ciph.random_nonce()
|
||||||
|
|
||||||
enc = a.Encryptor(key, nonce, ad=b"header")
|
enc = ciph.Encryptor(key, nonce, ad=b"header", maclen=16)
|
||||||
c1 = enc.update(b"chunk1")
|
c1 = enc.update(b"chunk1")
|
||||||
c2 = enc.update(b"chunk2")
|
c2 = enc.update(b"chunk2")
|
||||||
mac = enc.final(maclen=16)
|
mac = enc.final()
|
||||||
|
|
||||||
dec = a.Decryptor(key, nonce, ad=b"header")
|
dec = ciph.Decryptor(key, nonce, ad=b"header", maclen=16)
|
||||||
p1 = dec.update(c1)
|
p1 = dec.update(c1)
|
||||||
p2 = dec.update(c2)
|
p2 = dec.update(c2)
|
||||||
dec.final(mac) # raises ValueError on failure
|
dec.final(mac) # raises ValueError on failure
|
||||||
@@ -173,7 +187,7 @@ message = bytearray(30 * b"Attack at dawn! ")
|
|||||||
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms
|
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms
|
||||||
nonce = ciph.random_nonce()
|
nonce = ciph.random_nonce()
|
||||||
framebytes = 80 # In real applications 1 MiB or more is practical
|
framebytes = 80 # In real applications 1 MiB or more is practical
|
||||||
maclen = ciph.ABYTES_MIN # 16
|
maclen = ciph.MACBYTES # 16
|
||||||
|
|
||||||
with open("encrypted.bin", "wb") as f:
|
with open("encrypted.bin", "wb") as f:
|
||||||
f.write(nonce) # Public initial nonce sent with the ciphertext
|
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
|
# Decryption needs same values as encryption
|
||||||
key = b"sixteenbyte key!"
|
key = b"sixteenbyte key!"
|
||||||
framebytes = 80
|
framebytes = 80
|
||||||
maclen = ciph.ABYTES_MIN
|
maclen = ciph.MACBYTES
|
||||||
|
|
||||||
with open("encrypted.bin", "rb") as f:
|
with open("encrypted.bin", "rb") as f:
|
||||||
nonce = bytearray(f.read(ciph.NPUBBYTES))
|
nonce = bytearray(f.read(ciph.NONCEBYTES))
|
||||||
while True:
|
while True:
|
||||||
frame = f.read(framebytes)
|
frame = f.read(framebytes)
|
||||||
if not frame:
|
if not frame:
|
||||||
@@ -204,12 +218,43 @@ with open("encrypted.bin", "rb") as f:
|
|||||||
print(pt)
|
print(pt)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Random generator
|
||||||
|
|
||||||
|
The stream generator is much faster than any traditional random number generator, cryptographically secure and seekable. Use `random_key()` for unpredictable output.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from 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=)
|
### 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 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.
|
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:
|
In-place operations are supported when the input and the output point to the same location in memory. When using attached MAC tag, the input buffer needs to be sliced to correct length:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+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
+361
-265
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
/* This file is generated with tools/gen_cdef.py. Do not edit. */
|
/* This file is generated with tools/generate.py. Do not edit. */
|
||||||
|
|
||||||
typedef unsigned char uint8_t;
|
typedef unsigned char uint8_t;
|
||||||
typedef unsigned long size_t;
|
typedef unsigned long size_t;
|
||||||
|
|||||||
+28
-44
@@ -3,13 +3,13 @@
|
|||||||
Python benchmark matching src/test/benchmark.zig for all supported Aegis algorithms.
|
Python benchmark matching src/test/benchmark.zig for all supported Aegis algorithms.
|
||||||
|
|
||||||
It performs two benchmarks with the same parameters as the Zig version:
|
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)
|
- AEGIS MAC (clone state pattern)
|
||||||
|
|
||||||
Output format and throughput units mirror the Zig benchmark (Mb/s).
|
Output format and throughput units mirror the Zig benchmark (Mb/s).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
|
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
|
MSG_LEN = 16384000 # 16 000 KiB
|
||||||
ITERATIONS = 100
|
ITERATIONS = 100
|
||||||
|
|
||||||
ALGORITHMS = [
|
|
||||||
("AEGIS-128L", aegis128l),
|
|
||||||
("AEGIS-128X2", aegis128x2),
|
|
||||||
("AEGIS-128X4", aegis128x4),
|
|
||||||
("AEGIS-256", aegis256),
|
|
||||||
("AEGIS-256X2", aegis256x2),
|
|
||||||
("AEGIS-256X4", aegis256x4),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
def bench_encrypt(ciph) -> None:
|
||||||
def _random_bytes(n: int) -> bytes:
|
key = ciph.random_key()
|
||||||
return os.urandom(n)
|
nonce = ciph.random_nonce()
|
||||||
|
|
||||||
|
|
||||||
def bench_encrypt(alg_name: str, a) -> None:
|
|
||||||
key = _random_bytes(a.KEYBYTES)
|
|
||||||
nonce = _random_bytes(a.NPUBBYTES)
|
|
||||||
|
|
||||||
# Single buffer, as in Zig: c_out == m buffer, with tag appended
|
# Single buffer, as in Zig: c_out == m buffer, with tag appended
|
||||||
maclen = a.ABYTES_MIN
|
maclen = ciph.MACBYTES
|
||||||
buf = bytearray(MSG_LEN + maclen)
|
buf = bytearray(MSG_LEN + maclen)
|
||||||
# Initialize buffer with random data
|
# Initialize buffer with random data
|
||||||
buf[:] = _random_bytes(len(buf))
|
buf[:] = secrets.token_bytes(len(buf))
|
||||||
|
|
||||||
mview = memoryview(buf)[:MSG_LEN]
|
mview = memoryview(buf)[:MSG_LEN]
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
for _ in range(ITERATIONS):
|
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()
|
t1 = time.perf_counter()
|
||||||
|
|
||||||
# Prevent any unrealistic optimization assumptions
|
# Prevent any unrealistic optimization assumptions
|
||||||
@@ -56,24 +43,21 @@ def bench_encrypt(alg_name: str, a) -> None:
|
|||||||
throughput_mbps = (
|
throughput_mbps = (
|
||||||
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
|
(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:
|
def bench_mac(ciph) -> None:
|
||||||
key = _random_bytes(a.KEYBYTES)
|
key = ciph.random_key()
|
||||||
nonce = _random_bytes(a.NPUBBYTES)
|
nonce = ciph.random_nonce()
|
||||||
|
|
||||||
buf = bytearray(MSG_LEN)
|
buf = bytearray(MSG_LEN)
|
||||||
buf[:] = _random_bytes(len(buf))
|
buf[:] = secrets.token_bytes(len(buf))
|
||||||
|
|
||||||
mac0 = a.Mac(key, nonce)
|
mac_out = bytearray(ciph.MACBYTES_LONG)
|
||||||
mac_out = bytearray(a.ABYTES_MAX)
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
for _ in range(ITERATIONS):
|
for _ in range(ITERATIONS):
|
||||||
mac = mac0.clone()
|
ciph.mac(key, nonce, buf, maclen=ciph.MACBYTES_LONG, into=mac_out)
|
||||||
mac.update(buf)
|
|
||||||
mac.final(maclen=a.ABYTES_MAX, into=mac_out)
|
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
|
|
||||||
_ = mac_out[0]
|
_ = mac_out[0]
|
||||||
@@ -83,23 +67,23 @@ def bench_mac(alg_name: str, a) -> None:
|
|||||||
throughput_mbps = (
|
throughput_mbps = (
|
||||||
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
|
(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__":
|
if __name__ == "__main__":
|
||||||
# aegis_init() is called in the loader at import time already
|
# aegis_init() is called in the loader at import time already
|
||||||
# Run encrypt benchmarks in order: 256, 256x2, 256x4, 128l, 128x2, 128x4
|
# Run encrypt benchmarks in order: 256, 256x2, 256x4, 128l, 128x2, 128x4
|
||||||
bench_encrypt("AEGIS-256", aegis256)
|
bench_encrypt(aegis256)
|
||||||
bench_encrypt("AEGIS-256X2", aegis256x2)
|
bench_encrypt(aegis256x2)
|
||||||
bench_encrypt("AEGIS-256X4", aegis256x4)
|
bench_encrypt(aegis256x4)
|
||||||
bench_encrypt("AEGIS-128L", aegis128l)
|
bench_encrypt(aegis128l)
|
||||||
bench_encrypt("AEGIS-128X2", aegis128x2)
|
bench_encrypt(aegis128x2)
|
||||||
bench_encrypt("AEGIS-128X4", aegis128x4)
|
bench_encrypt(aegis128x4)
|
||||||
|
|
||||||
# Run MAC benchmarks in order: 128l, 128x2, 128x4, 256, 256x2, 256x4
|
# Run MAC benchmarks in order: 128l, 128x2, 128x4, 256, 256x2, 256x4
|
||||||
bench_mac("AEGIS-128L", aegis128l)
|
bench_mac(aegis128l)
|
||||||
bench_mac("AEGIS-128X2", aegis128x2)
|
bench_mac(aegis128x2)
|
||||||
bench_mac("AEGIS-128X4", aegis128x4)
|
bench_mac(aegis128x4)
|
||||||
bench_mac("AEGIS-256", aegis256)
|
bench_mac(aegis256)
|
||||||
bench_mac("AEGIS-256X2", aegis256x2)
|
bench_mac(aegis256x2)
|
||||||
bench_mac("AEGIS-256X4", aegis256x4)
|
bench_mac(aegis256x4)
|
||||||
|
|||||||
+29
-18
@@ -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.
|
on libc/posix_memalign. Memory is owned by Python; C code only borrows it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from ._loader import ffi
|
from ._loader import ffi
|
||||||
@@ -13,14 +11,13 @@ from ._loader import ffi
|
|||||||
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
|
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from collections.abc import Buffer as _Buffer
|
from collections.abc import Buffer as _Buffer # type: ignore[misc]
|
||||||
|
|
||||||
class Buffer(_Buffer, Protocol): # type: ignore[misc]
|
class Buffer(_Buffer, Protocol): # type: ignore[misc]
|
||||||
def __len__(self) -> int: ...
|
pass
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
||||||
class Buffer(Protocol):
|
class Buffer(Protocol):
|
||||||
def __len__(self) -> int: ...
|
|
||||||
def __buffer__(self, flags: int) -> memoryview: ...
|
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)))
|
return int(ffi.cast("uintptr_t", ffi.addressof(obj, 0)))
|
||||||
|
|
||||||
|
|
||||||
def new_aligned_struct(ctype: str, alignment: int) -> tuple[object, object]:
|
class StructHolder:
|
||||||
"""Allocate memory for one instance of ``ctype`` with requested alignment.
|
"""Proxy object for aligned struct allocation.
|
||||||
|
|
||||||
This allocates a Python-owned unsigned char[] buffer large enough to find
|
Exposes the aligned pointer as a property and wipes the buffer on deletion.
|
||||||
an aligned start address. Returns (ptr, owner) where ptr is a ``ctype *``
|
|
||||||
and owner is the buffer object keeping the memory alive.
|
|
||||||
"""
|
"""
|
||||||
if alignment & (alignment - 1): # Not power of two
|
|
||||||
raise ValueError("alignment must be a power of two")
|
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)
|
size = ffi.sizeof(ctype)
|
||||||
base = ffi.new("unsigned char[]", size + alignment - 1)
|
view = memoryview(bytearray(size + alignment - 1))
|
||||||
addr = aligned_address(base)
|
# Compute alignment offset from the base address
|
||||||
offset = (-addr) & (alignment - 1)
|
offset = (-aligned_address(ffi.from_buffer(view))) & (alignment - 1)
|
||||||
aligned_uc = ffi.addressof(base, offset)
|
# Slice the memoryview to the aligned region (keeps bytearray alive)
|
||||||
ptr = ffi.cast(f"{ctype} *", aligned_uc)
|
view = view[offset : offset + size]
|
||||||
return ptr, base
|
return StructHolder(ffi.from_buffer(f"{ctype} *", view), view)
|
||||||
|
|
||||||
|
|
||||||
def nonce_increment(nonce: Buffer) -> None:
|
def nonce_increment(nonce: Buffer) -> None:
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ backend-path = ["tools"]
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyaegis"
|
name = "pyaegis"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Python bindings for libaegis"
|
description = "Python bindings for libaegis"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|||||||
+8
-12
@@ -147,14 +147,12 @@ def test_encrypt_decrypt_incremental(vector):
|
|||||||
expected_tag128 = bytes.fromhex(vector["tag128"])
|
expected_tag128 = bytes.fromhex(vector["tag128"])
|
||||||
|
|
||||||
# Incremental encryption with random chunking
|
# Incremental encryption with random chunking
|
||||||
encryptor = alg.Encryptor(key, nonce, ad)
|
encryptor = alg.Encryptor(key, nonce, ad, maclen=16)
|
||||||
ct_chunks = []
|
ct_chunks = []
|
||||||
for chunk in random_split_bytes(msg):
|
for chunk in random_split_bytes(msg):
|
||||||
ct_result = encryptor.update(chunk)
|
ct_result = encryptor.update(chunk)
|
||||||
ct_chunks.append(bytes(ct_result))
|
ct_chunks.append(bytes(ct_result))
|
||||||
final_output = encryptor.final(maclen=16)
|
computed_mac = bytes(encryptor.final())
|
||||||
ct_chunks.append(bytes(final_output[:-16])) # ciphertext part
|
|
||||||
computed_mac = bytes(final_output[-16:]) # MAC part
|
|
||||||
|
|
||||||
# Combine ciphertext chunks
|
# Combine ciphertext chunks
|
||||||
computed_ct = b"".join(ct_chunks)
|
computed_ct = b"".join(ct_chunks)
|
||||||
@@ -170,7 +168,7 @@ def test_encrypt_decrypt_incremental(vector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Incremental decryption with different random chunking
|
# Incremental decryption with different random chunking
|
||||||
decryptor = alg.Decryptor(key, nonce, ad)
|
decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
|
||||||
pt_chunks = []
|
pt_chunks = []
|
||||||
for chunk in random_split_bytes(computed_ct):
|
for chunk in random_split_bytes(computed_ct):
|
||||||
pt_chunks.append(bytes(decryptor.update(chunk)))
|
pt_chunks.append(bytes(decryptor.update(chunk)))
|
||||||
@@ -187,14 +185,12 @@ def test_encrypt_decrypt_incremental(vector):
|
|||||||
expected_tag256 = bytes.fromhex(vector["tag256"])
|
expected_tag256 = bytes.fromhex(vector["tag256"])
|
||||||
|
|
||||||
# Incremental encryption with random chunking
|
# Incremental encryption with random chunking
|
||||||
encryptor = alg.Encryptor(key, nonce, ad)
|
encryptor = alg.Encryptor(key, nonce, ad, maclen=32)
|
||||||
ct_chunks = []
|
ct_chunks = []
|
||||||
for chunk in random_split_bytes(msg):
|
for chunk in random_split_bytes(msg):
|
||||||
ct_result = encryptor.update(chunk)
|
ct_result = encryptor.update(chunk)
|
||||||
ct_chunks.append(bytes(ct_result))
|
ct_chunks.append(bytes(ct_result))
|
||||||
final_output = encryptor.final(maclen=32)
|
computed_mac = bytes(encryptor.final())
|
||||||
ct_chunks.append(bytes(final_output[:-32])) # ciphertext part
|
|
||||||
computed_mac = bytes(final_output[-32:]) # MAC part
|
|
||||||
|
|
||||||
# Combine ciphertext chunks
|
# Combine ciphertext chunks
|
||||||
computed_ct = b"".join(ct_chunks)
|
computed_ct = b"".join(ct_chunks)
|
||||||
@@ -210,7 +206,7 @@ def test_encrypt_decrypt_incremental(vector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Incremental decryption with different random chunking
|
# Incremental decryption with different random chunking
|
||||||
decryptor = alg.Decryptor(key, nonce, ad)
|
decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
|
||||||
pt_chunks = []
|
pt_chunks = []
|
||||||
for chunk in random_split_bytes(computed_ct):
|
for chunk in random_split_bytes(computed_ct):
|
||||||
pt_chunks.append(bytes(decryptor.update(chunk)))
|
pt_chunks.append(bytes(decryptor.update(chunk)))
|
||||||
@@ -229,14 +225,14 @@ def test_encrypt_decrypt_incremental(vector):
|
|||||||
# Test that incremental decryption fails with the provided (invalid) MACs
|
# Test that incremental decryption fails with the provided (invalid) MACs
|
||||||
if "tag128" in vector:
|
if "tag128" in vector:
|
||||||
invalid_mac = bytes.fromhex(vector["tag128"])
|
invalid_mac = bytes.fromhex(vector["tag128"])
|
||||||
decryptor = alg.Decryptor(key, nonce, ad)
|
decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
|
||||||
decryptor.update(ct) # This should succeed
|
decryptor.update(ct) # This should succeed
|
||||||
with pytest.raises(ValueError, match="authentication failed"):
|
with pytest.raises(ValueError, match="authentication failed"):
|
||||||
decryptor.final(invalid_mac)
|
decryptor.final(invalid_mac)
|
||||||
|
|
||||||
if "tag256" in vector:
|
if "tag256" in vector:
|
||||||
invalid_mac = bytes.fromhex(vector["tag256"])
|
invalid_mac = bytes.fromhex(vector["tag256"])
|
||||||
decryptor = alg.Decryptor(key, nonce, ad)
|
decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
|
||||||
decryptor.update(ct) # This should succeed
|
decryptor.update(ct) # This should succeed
|
||||||
with pytest.raises(ValueError, match="authentication failed"):
|
with pytest.raises(ValueError, match="authentication failed"):
|
||||||
decryptor.final(invalid_mac)
|
decryptor.final(invalid_mac)
|
||||||
|
|||||||
+172
-4
@@ -2,10 +2,14 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
|
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
|
||||||
|
|
||||||
from .util import random_split_bytes
|
from .util import random_split_bytes
|
||||||
|
|
||||||
|
# All AEGIS algorithm modules
|
||||||
|
ALL_ALGORITHMS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
|
||||||
|
|
||||||
|
|
||||||
def load_mac_test_vectors():
|
def load_mac_test_vectors():
|
||||||
"""Load MAC test vectors from JSON file."""
|
"""Load MAC test vectors from JSON file."""
|
||||||
@@ -81,10 +85,10 @@ def test_mac_class(vector):
|
|||||||
# Test 128-bit MAC if present
|
# Test 128-bit MAC if present
|
||||||
if "tag128" in vector:
|
if "tag128" in vector:
|
||||||
expected_tag128 = bytes.fromhex(vector["tag128"])
|
expected_tag128 = bytes.fromhex(vector["tag128"])
|
||||||
mac_state = alg.Mac(key, nonce)
|
mac_state = alg.Mac(key, nonce, maclen=16)
|
||||||
for chunk in random_split_bytes(data):
|
for chunk in random_split_bytes(data):
|
||||||
mac_state.update(chunk)
|
mac_state.update(chunk)
|
||||||
computed_tag128 = mac_state.final(maclen=16)
|
computed_tag128 = mac_state.final()
|
||||||
assert computed_tag128 == expected_tag128, (
|
assert computed_tag128 == expected_tag128, (
|
||||||
f"128-bit MAC mismatch for {vector['name']}"
|
f"128-bit MAC mismatch for {vector['name']}"
|
||||||
)
|
)
|
||||||
@@ -92,10 +96,174 @@ def test_mac_class(vector):
|
|||||||
# Test 256-bit MAC if present
|
# Test 256-bit MAC if present
|
||||||
if "tag256" in vector:
|
if "tag256" in vector:
|
||||||
expected_tag256 = bytes.fromhex(vector["tag256"])
|
expected_tag256 = bytes.fromhex(vector["tag256"])
|
||||||
mac_state = alg.Mac(key, nonce)
|
mac_state = alg.Mac(key, nonce, maclen=32)
|
||||||
for chunk in random_split_bytes(data):
|
for chunk in random_split_bytes(data):
|
||||||
mac_state.update(chunk)
|
mac_state.update(chunk)
|
||||||
computed_tag256 = mac_state.final(maclen=32)
|
computed_tag256 = mac_state.final()
|
||||||
assert computed_tag256 == expected_tag256, (
|
assert computed_tag256 == expected_tag256, (
|
||||||
f"256-bit MAC mismatch for {vector['name']}"
|
f"256-bit MAC mismatch for {vector['name']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
|
||||||
|
def test_mac_class_with_digest(vector):
|
||||||
|
"""Test MAC computation using digest() and hexdigest() instead of final()."""
|
||||||
|
alg = get_algorithm_module(vector["name"])
|
||||||
|
|
||||||
|
key = bytes.fromhex(vector["key"])
|
||||||
|
nonce = bytes.fromhex(vector["nonce"])
|
||||||
|
data = bytes.fromhex(vector["data"])
|
||||||
|
|
||||||
|
# Test 128-bit MAC if present
|
||||||
|
if "tag128" in vector:
|
||||||
|
expected_tag128 = bytes.fromhex(vector["tag128"])
|
||||||
|
|
||||||
|
# Test with digest()
|
||||||
|
mac_state = alg.Mac(key, nonce, maclen=16)
|
||||||
|
for chunk in random_split_bytes(data):
|
||||||
|
mac_state.update(chunk)
|
||||||
|
computed_tag128 = mac_state.digest()
|
||||||
|
assert computed_tag128 == expected_tag128, (
|
||||||
|
f"128-bit MAC mismatch for {vector['name']} using digest()"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test that digest() can be called multiple times
|
||||||
|
computed_tag128_again = mac_state.digest()
|
||||||
|
assert computed_tag128 == computed_tag128_again, (
|
||||||
|
"digest() should return the same value on repeated calls"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test hexdigest()
|
||||||
|
mac_state2 = alg.Mac(key, nonce, maclen=16)
|
||||||
|
for chunk in random_split_bytes(data):
|
||||||
|
mac_state2.update(chunk)
|
||||||
|
hex_tag = mac_state2.hexdigest()
|
||||||
|
assert hex_tag == expected_tag128.hex(), (
|
||||||
|
f"128-bit MAC hexdigest mismatch for {vector['name']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test that hexdigest() can be called multiple times
|
||||||
|
hex_tag_again = mac_state2.hexdigest()
|
||||||
|
assert hex_tag == hex_tag_again, (
|
||||||
|
"hexdigest() should return the same value on repeated calls"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 256-bit MAC if present
|
||||||
|
if "tag256" in vector:
|
||||||
|
expected_tag256 = bytes.fromhex(vector["tag256"])
|
||||||
|
|
||||||
|
# Test with digest()
|
||||||
|
mac_state = alg.Mac(key, nonce, maclen=32)
|
||||||
|
for chunk in random_split_bytes(data):
|
||||||
|
mac_state.update(chunk)
|
||||||
|
computed_tag256 = mac_state.digest()
|
||||||
|
assert computed_tag256 == expected_tag256, (
|
||||||
|
f"256-bit MAC mismatch for {vector['name']} using digest()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
|
||||||
|
def test_mac_clone(vector):
|
||||||
|
"""Test that cloning a Mac state works correctly."""
|
||||||
|
alg = get_algorithm_module(vector["name"])
|
||||||
|
|
||||||
|
key = bytes.fromhex(vector["key"])
|
||||||
|
nonce = bytes.fromhex(vector["nonce"])
|
||||||
|
data = bytes.fromhex(vector["data"])
|
||||||
|
|
||||||
|
# Test 128-bit MAC if present
|
||||||
|
if "tag128" in vector:
|
||||||
|
expected_tag128 = bytes.fromhex(vector["tag128"])
|
||||||
|
|
||||||
|
mac_state = alg.Mac(key, nonce, maclen=16)
|
||||||
|
for chunk in random_split_bytes(data):
|
||||||
|
mac_state.update(chunk)
|
||||||
|
|
||||||
|
# Clone the state
|
||||||
|
cloned_state = mac_state.clone()
|
||||||
|
|
||||||
|
# Both should produce the same tag
|
||||||
|
tag1 = mac_state.final()
|
||||||
|
tag2 = cloned_state.final()
|
||||||
|
|
||||||
|
assert tag1 == expected_tag128
|
||||||
|
assert tag2 == expected_tag128
|
||||||
|
assert tag1 == tag2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
|
||||||
|
def test_mac_reset(vector):
|
||||||
|
"""Test that resetting a Mac state works correctly."""
|
||||||
|
alg = get_algorithm_module(vector["name"])
|
||||||
|
|
||||||
|
key = bytes.fromhex(vector["key"])
|
||||||
|
nonce = bytes.fromhex(vector["nonce"])
|
||||||
|
data = bytes.fromhex(vector["data"])
|
||||||
|
|
||||||
|
# Test 128-bit MAC if present
|
||||||
|
if "tag128" in vector:
|
||||||
|
expected_tag128 = bytes.fromhex(vector["tag128"])
|
||||||
|
|
||||||
|
mac_state = alg.Mac(key, nonce, maclen=16)
|
||||||
|
for chunk in random_split_bytes(data):
|
||||||
|
mac_state.update(chunk)
|
||||||
|
tag1 = mac_state.final()
|
||||||
|
assert tag1 == expected_tag128
|
||||||
|
|
||||||
|
# Reset and compute again
|
||||||
|
mac_state.reset()
|
||||||
|
for chunk in random_split_bytes(data):
|
||||||
|
mac_state.update(chunk)
|
||||||
|
tag2 = mac_state.final()
|
||||||
|
|
||||||
|
assert tag2 == expected_tag128
|
||||||
|
assert tag1 == tag2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1])
|
||||||
|
def test_mac_reset_after_digest(alg):
|
||||||
|
"""Test that reset() clears the cached digest and allows reuse."""
|
||||||
|
key = alg.random_key()
|
||||||
|
nonce = alg.random_nonce()
|
||||||
|
|
||||||
|
mac_state = alg.Mac(key, nonce)
|
||||||
|
mac_state.update(b"Hello, world!")
|
||||||
|
tag1 = mac_state.digest()
|
||||||
|
|
||||||
|
# After digest(), update should fail
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
mac_state.update(b"More data")
|
||||||
|
|
||||||
|
# Reset should clear the cached digest
|
||||||
|
mac_state.reset()
|
||||||
|
|
||||||
|
# Now we should be able to update again
|
||||||
|
mac_state.update(b"Different data")
|
||||||
|
tag2 = mac_state.digest()
|
||||||
|
|
||||||
|
# Tags should be different since we used different data
|
||||||
|
assert tag1 != tag2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1])
|
||||||
|
def test_mac_clone_preserves_cached_digest(alg):
|
||||||
|
"""Test that cloning preserves the cached digest state."""
|
||||||
|
key = alg.random_key()
|
||||||
|
nonce = alg.random_nonce()
|
||||||
|
|
||||||
|
mac_state = alg.Mac(key, nonce)
|
||||||
|
mac_state.update(b"Hello, world!")
|
||||||
|
tag1 = mac_state.digest()
|
||||||
|
|
||||||
|
# Clone after digest
|
||||||
|
cloned_state = mac_state.clone()
|
||||||
|
|
||||||
|
# Both should return the same cached tag
|
||||||
|
tag2 = cloned_state.digest()
|
||||||
|
assert tag1 == tag2
|
||||||
|
|
||||||
|
# Both should be unable to update
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
mac_state.update(b"More data")
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
cloned_state.update(b"More data")
|
||||||
|
|||||||
@@ -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