52 Commits
Author SHA1 Message Date
LeoVasanko 9583a1f9a8 Fix cdef size_t size on Win64 (LLP64), require 64-bit builds
release / build-wheels (linux, bash) (push) Successful in 1m12s
release / build-wheels (windows, cmd) (push) Failing after 1m16s
release / build-wheels (macos, bash) (push) Successful in 1m21s
release / publish-pypi (push) Skipped
The generated cdef typedef'd size_t as unsigned long: 8 bytes on LP64
but only 4 on 64-bit Windows, where C's size_t is 8. cffi's import-time
struct layout verification then failed (aegis_raf_scratch.len: cdef
says 4, C compiler says 8). cffi pre-defines size_t/uint8_t with the
correct platform sizes, so don't typedef them.

Also hard-fail 32-bit build attempts in setup.py; 32-bit platforms are
no longer supported.
2026-10-04 17:32:52 +00:00
LeoVasanko c3c4d66ea8 README 2026-10-04 17:31:11 +00:00
LeoVasanko f7f3a450c9 CI: build Windows wheels outside %WINDIR%
release / build-wheels (linux, bash) (push) Successful in 1m12s
release / build-wheels (macos, bash) (push) Successful in 1m27s
release / build-wheels (windows, cmd) (push) Failing after 1m37s
release / publish-pypi (push) Skipped
Windows runners run as SYSTEM with the workspace under
C:\Windows\System32\config\systemprofile. cl.exe is 32-bit, so WOW64
file redirection sends its System32 access to SysWOW64 and it cannot
see the checked-out sources (C1083: aegis.h not found), while 64-bit
processes (git, python) see them fine. Move the Windows checkout/build
to C:\aeg-build.
2026-10-04 17:08:26 +00:00
LeoVasanko 696b35eb6f CI fixes: patchelf dev dep for auditwheel, absolute paths in setup.py
release / build-wheels (windows, cmd) (push) Failing after 11s
release / build-wheels (linux, bash) (push) Successful in 1m17s
release / build-wheels (macos, bash) (push) Successful in 1m23s
release / publish-pypi (push) Skipped
- Linux runner lacks patchelf; add the patchelf PyPI package (linux
  only) to dev deps so uv run puts it on PATH for auditwheel repair.
- setup.py anchored all libaegis paths to __file__: newer setuptools
  invoke the compiler from a temp build dir, breaking CWD-relative
  -Ilibaegis/src/include on Windows (C1083 aegis.h not found).
2026-10-04 17:01:03 +00:00
LeoVasanko 276b2faa0b benchmark.py: uv run shebang, executable bit, stdlib-random shadowing guard
release / build-wheels (windows, cmd) (push) Failing after 10s
release / build-wheels (linux, bash) (push) Failing after 47s
release / build-wheels (macos, bash) (push) Successful in 1m25s
release / publish-pypi (push) Skipped
Fixes CI EXE001 (shebang on non-executable file). Running the script
directly puts src/aeg on sys.path, where aeg/random.py would shadow the
stdlib random module that secrets and others import — pop it before any
imports so both direct execution and -m aeg.benchmark work.
2026-10-04 16:50:37 +00:00
LeoVasanko b4404b42e6 Fix ruff 0.16 findings; bump dependency minimums to match
release / build-wheels (windows, cmd) (push) Failing after 11s
release / build-wheels (linux, bash) (push) Failing after 10s
release / build-wheels (macos, bash) (push) Failing after 11s
release / publish-pypi (push) Skipped
- pyproject [tool.ruff.lint]: ignore RUF022/RUF023 (deliberately grouped
  __all__/__slots__), SIM117, BLE001 (blind except is intentional at
  CFFI callback boundaries)
- Legit fixes: dict/tuple instead of typing.Dict/Tuple in generate.py,
  unquoted annotations in raf.py, Self return for __enter__ methods,
  explicit check=False on subprocess.run, collapsed nested ifs, sorted
  test imports, unused-unpacked-variable in test_raises
- Bump minimum versions to the synced toolchain: cffi 2.1.1, ruff
  0.16.10, pytest 9.1.1, auditwheel 6.8.2, setuptools 84.0.0,
  setuptools-scm 10.3.4 (project deps and build-system requires)
2026-10-04 16:24:40 +00:00
LeoVasanko 1eb0b6e8c0 Add stream_xor(); deprecate encrypt/decrypt_unauthenticated
release / build-wheels (linux, bash) (push) Failing after 15s
release / build-wheels (windows, cmd) (push) Failing after 1m14s
release / build-wheels (macos, bash) (push) Failing after 18s
release / publish-pypi (push) Skipped
Following upstream libaegis 0.10.5 (874f884): the absorbing-mode
unauthenticated pair is deprecated in favor of the pure keystream XOR.
- stream_xor(key, nonce, data, into=None): same function encrypts and
  decrypts; output equals data XOR stream(key, nonce); supports in-place
  use via into (same buffer semantics as elsewhere).
- encrypt_unauthenticated/decrypt_unauthenticated kept for the old
  format but now emit DeprecationWarning; removed from README (stream_xor
  documented under Keystream generation instead).
2026-10-04 15:57:28 +00:00
LeoVasanko 9d36d3b81b random(): per-thread singleton; random_key/random_nonce use it
Each cipher module's random() now returns a thread-local Random that is
created on first use and reused thereafter. Each thread gets its own
OS-seeded generator (own key+nonce), so no locking and no cross-thread
nonce collisions. random_key() and random_nonce() draw from this CSPRNG
instead of secrets.token_bytes, making them as fast as the stream.
Changes made in the aegis256x4.py template and regenerated.
2026-10-04 15:42:02 +00:00
LeoVasanko 32954c4eba aeg.raf: StreamStorage adapter; accept io.BytesIO directly
StreamStorage wraps any seek-based file-like object using the same
lock + seek/read/write pattern as FileStorage's fallback for platforms
without pread/pwrite. create()/open() auto-wrap io.BytesIO, so in-memory
use no longer requires the explicit BytesIOStorage wrapper. The caller
keeps ownership of the stream (Raf does not close it).
2026-10-04 14:30:59 +00:00
LeoVasanko f2a21856b3 README: brief docs for aeg.random, aeg.raf and SecureBuffer 2026-10-04 05:44:45 +00:00
LeoVasanko faa24cf22e Add aeg.securebuf.SecureBuffer: locked, wipe-on-close memory
mmap-backed buffer locked against swapping (mlock/VirtualLock) and
excluded from core dumps (MADV_DONTDUMP) where available, wiped on
close. Not used for return values; intended as an 'into' target (and
key input) to keep sensitive material out of the regular heap.
Adapted from quantumpipe's securebuf module.
2026-10-04 05:44:04 +00:00
LeoVasanko 972c3053d9 Require Python 3.12+; drop PyPy and 3.10/3.11 wheel builds
- requires-python >= 3.12; abi3 limited API tag cp310 -> cp312
- util.Buffer now imported directly from collections.abc (no fallback)
- release.py: ABI3 wheel covers 3.12-3.15; PyPy wheels dropped (PyPy
  has no 3.12 support yet)
2026-10-04 05:42:22 +00:00
LeoVasanko 199dfe7bd9 generate.py: run ruff format on generated files
Running generate.py standalone previously rewrote _ciphers.py with
formatting that differed from the committed (ruff-formatted) style,
creating spurious diffs. release.py already ran ruff after generation;
now generate.py does too, so both entry points leave the tree in the
same state.
2026-10-04 05:41:14 +00:00
LeoVasanko e01d84a560 Add RAF (random-access encrypted file) support and aeg.random CSPRNG
Implement Python bindings for the libaegis 0.10.6 RAF API:

- Thin per-variant wrappers (raf_create/open/read/write/truncate/size/
  sync/close, merkle_rebuild/verify/commitment) generated from a template
  in tools/generate.py, with errno-mapped exceptions.
- aeg.raf: high-level file-like API (Raf, create/open/probe,
  derive_master_key) with pluggable Storage backends (FileStorage,
  BytesIOStorage) and Merkle tree integrity via a MerkleHasher protocol
  (SHA256MerkleHasher default), adapted from pyaegis (MIT).
- aeg.random: CSPRNG facility. Random produces AEGIS keystream seeded
  once from the OS, exposed per cipher module as cipher.random() and
  usable as the RAF RNG (as_raf_rng) or standalone RandomSource.
- Tests: roundtrip/random-access/seek/truncate/Merkle coverage for all
  six variants, pyaegis-style API checks, and a Merkle fuzz test.
2026-10-04 04:25:59 +00:00
LeoVasanko e70c7a37d2 Update libaegis submodule 0.9.0 -> 0.10.6
89 upstream commits: Zig 0.16 compat, symbol namespacing, opaque state
structs, new stream_xor and random-access file APIs. Regenerated
aegis_cdef.h; Python modules unchanged. Full release build verified:
5 wheels + sdist, tests/benchmarks pass on all 10 Python versions.
2026-10-04 02:58:47 +00:00
LeoVasanko 0cb7faf698 Add Gitea release workflow: per-platform wheel builds, then publish to PyPI
Matrix job builds and tests wheels on linux/macos/windows (no Gitea
release, unlike mediahive); publish job uploads the merged dist/ to PyPI
only after all platforms succeed. release.py gains --no-upload for the
matrix jobs.
2026-10-04 02:54:30 +00:00
LeoVasanko a1bfeb2434 Build updated for upstream libaegis 0.9.0 simplified API with matching changes on Python side. Set lower MacOS build target (11.0). Build abi3 version for all supported Python versions, reducing the number of wheels. Other build cleanup. 2026-01-10 20:23:37 +00:00
LeoVasanko a26ce6a9fb Considered stable from here onwards. 2026-01-10 20:22:44 +00:00
LeoVasanko 7e6a5bf52a Allow the cipher() function also work without prefix aegis 2026-01-04 14:25:39 +00:00
LeoVasanko 8018e68274 Add nonce_increment and wipe functions on typing. 2026-01-04 14:19:01 +00:00
LeoVasanko ccccfd7918 README: Add the new aeg.cipher() function for fetching variants. 2025-12-31 22:51:19 +00:00
LeoVasanko 4de66b6bc6 [packaging] Release script: Fix further problems with freethreaded wheels and a variable name overlap. 2025-12-31 22:30:28 +00:00
LeoVasanko 66338ef416 [packaging] Fix wheel curing for pypy-linux. 2025-12-31 21:59:37 +00:00
LeoVasanko 509898ac73 Make tool scripts executable. 2025-12-31 21:43:25 +00:00
LeoVasanko 67ec091bc5 Add aeg.cipher(alg) for loading modules by name in str. Added aeg.CIPHERS mapping of the nominal names to module names and Cipher for typing literals. 2025-12-31 21:38:30 +00:00
LeoVasanko 4977a7e79d Added free-threaded non-GIL Python and PyPy wheels, up to 3.15 now. 2025-12-31 21:24:56 +00:00
LeoVasanko d7acf7a315 [release script] Auditwheel only on Linux. 2025-12-23 19:38:59 +00:00
LeoVasanko 7f01de8327 Add README for PyPI. 2025-12-23 19:33:00 +00:00
LeoVasanko 9294fba86d Make manylinux wheels. 2025-12-23 19:20:17 +00:00
LeoVasanko 348812e750 Clean up for release. 2025-12-23 19:09:59 +00:00
LeoVasanko 22dff32b22 Clean up for release. 2025-12-23 19:08:10 +00:00
LeoVasanko 178b689a35 Clean up for release. 2025-12-23 18:54:03 +00:00
LeoVasanko bd8b183e32 Project renamed to aeg, preparing for PyPI release. 2025-12-23 18:25:03 +00:00
LeoVasanko daba717ebb Add tools/release.py script for automatic release process. 2025-11-12 23:47:26 +00:00
LeoVasanko 5b74bcb683 Always generate LF line endings (even on broken OS). 2025-11-12 23:43:25 +00:00
LeoVasanko d9a18f43d5 Ruff formatting. 2025-11-12 23:43:04 +00:00
LeoVasanko 98ee4ab7b3 Fixed Windows build, cleaned up messages during build, using SCM version numbering, added ruff. 2025-11-12 22:59:21 +00:00
LeoVasanko c6148806dd Update pyproject, bump version. 2025-11-10 08:41:40 +00:00
LeoVasanko 6e6bc49466 README tuning. New benchmark results (a bit slower than initial versions were). 2025-11-10 08:40:04 +00:00
LeoVasanko c2ac2e2790 Use a much faster method to wipe buffers. 2025-11-10 08:38:28 +00:00
LeoVasanko c6e77cac66 Cleanup. 2025-11-10 08:00:12 +00:00
LeoVasanko df1acadb1f Bump version 2025-11-09 21:47:08 +00:00
LeoVasanko cb0f9956c3 Convert all input buffers to memoryview before use and use .nbytes, because len() doesn't work correctly with some buffers. Update docs with a Numpy example. 2025-11-09 21:19:50 +00:00
LeoVasanko a29405455a Cleanup benchmark 2025-11-09 20:59:53 +00:00
LeoVasanko 0d63844201 Make Mac class prevent further updates or final after finalisation. Keep cached values for hashlib API. 2025-11-09 20:53:19 +00:00
LeoVasanko 7682d5bc8b Cleanup 2025-11-09 08:39:41 +00:00
LeoVasanko 26b883ecbc Simplify implementation: remove bytes_in and bytes_out counters from all classes. 2025-11-09 08:09:26 +00:00
LeoVasanko 87d9a07868 Wipe state structs automatically after use. Simplified aligned allocator and its use via a single handle. 2025-11-09 08:04:00 +00:00
LeoVasanko eed36e2463 API updates:
- Mac class follows hashlib API: digest functions added and finalization no longer modifies state.
- Encryptor and Decryptor now raise RuntimeError if still used after final.

Documentation updated with the changes and  further examples.

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

Docstrings improved.
2025-11-09 06:53:27 +00:00
LeoVasanko 46bc05f547 Add human-readable algorithm name as NAME constant. 2025-11-09 04:01:05 +00:00
LeoVasanko 56d304a82e Constants renamed and values extracted from C code rather than function call at runtime. Documentation update. 2025-11-09 03:43:01 +00:00
LeoVasanko 28ee0e1314 Combine the two generator scripts into one that also reads ALIGNMENT and RATE from C sources. 2025-11-09 01:20:15 +00:00
49 changed files with 13818 additions and 5937 deletions

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
name: release
on:
push:
tags:
- "v*"
# Runner host prerequisites: git, uv, zig, and a C compiler.
jobs:
build-wheels:
strategy:
fail-fast: false
matrix:
include:
- os: macos
shell: bash
- os: windows
shell: cmd
- os: linux
shell: bash
runs-on: ${{ matrix.os }}
steps:
# Plain git clone: full history so setuptools_scm sees tags, and both
# shells work. Windows uses cmd: bash resolves to WSL (refuses SYSTEM
# accounts) and powershell hits the script execution policy under SYSTEM.
# --recursive pulls in the libaegis submodule needed by the build backend.
- name: Checkout
if: matrix.os != 'windows'
shell: ${{ matrix.shell }}
run: |
git clone --recursive "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout -f "${{ gitea.sha }}"
# Windows runners execute as SYSTEM with the workspace under
# C:\Windows\System32\config\systemprofile. cl.exe is a 32-bit binary,
# so WOW64 redirects its file access to SysWOW64 and it cannot see the
# sources (C1083: aegis.h not found). Build from a path outside %WINDIR%.
- name: Checkout
if: matrix.os == 'windows'
shell: cmd
run: |
if exist C:\aeg-build rmdir /s /q C:\aeg-build
git clone --recursive "${{ gitea.server_url }}/${{ gitea.repository }}.git" C:\aeg-build
cd /d C:\aeg-build
git checkout -f "${{ gitea.sha }}"
# Builds sdist + all wheel variants for this platform, runs tests and
# benchmarks, leaves everything in dist/. Upload is left to publish-pypi.
- name: Build wheels for this platform
if: matrix.os != 'windows'
shell: ${{ matrix.shell }}
run: uv run tools/release.py --no-upload
- name: Build wheels for this platform
if: matrix.os == 'windows'
shell: cmd
run: |
cd /d C:\aeg-build
uv run tools/release.py --no-upload
- name: Upload wheels as artifact
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: ${{ matrix.os == 'windows' && 'C:\aeg-build\dist\' || 'dist/' }}
retention-days: 1
# Publish to PyPI only once every platform's wheel build has succeeded.
# Jobs don't share a workspace, so the per-platform dist/ directories are
# passed around as artifacts and merged here. No Gitea release is created.
publish-pypi:
needs: build-wheels
runs-on: linux
steps:
- name: Download all platform wheels
uses: actions/download-artifact@v4
with:
pattern: dist-*
path: dist
merge-multiple: true
# The sdist is identical across platforms; --skip-existing also makes
# reruns of a partially-published release safe.
- name: Publish to PyPI
shell: bash
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: uvx twine upload --skip-existing dist/**
+4 -3
View File
@@ -3,9 +3,10 @@
*.egg-info *.egg-info
/dist /dist
/build /build
/pyaegis/build /src/aeg/build
/pyaegis/_aegis.*.so /src/aeg/_aegis*.so
/pyaegis/_aegis.*.pyd /src/aeg/_aegis*.pyd
__pycache__ __pycache__
!.gitignore !.gitignore
!.gitea/
!.gitmodules !.gitmodules
+8 -14
View File
@@ -1,6 +1,6 @@
# Building pyaegis # Building aeg
This document contains instructions for developers who want to build pyaegis from source. This document contains instructions for developers who want to build aeg from source.
## Prerequisites ## Prerequisites
@@ -11,7 +11,7 @@ This document contains instructions for developers who want to build pyaegis fro
### Installing Zig ### Installing Zig
pyaegis uses Zig to build the underlying libaegis C library. Install Zig from [ziglang.org/download](https://ziglang.org/download/) or using your package manager: aeg uses Zig to build the underlying libaegis C library. Install Zig from [ziglang.org/download](https://ziglang.org/download/) or using your package manager:
- **macOS**: `brew install zig` - **macOS**: `brew install zig`
- **Linux**: See [Zig installation guide](https://github.com/ziglang/zig/wiki/Install-Zig-from-a-Package-Manager) - **Linux**: See [Zig installation guide](https://github.com/ziglang/zig/wiki/Install-Zig-from-a-Package-Manager)
@@ -22,8 +22,8 @@ pyaegis uses Zig to build the underlying libaegis C library. Install Zig from [z
Clone the repository with submodules: Clone the repository with submodules:
```fish ```fish
git clone --recursive https://github.com/LeoVasanko/pyaegis.git git clone --recursive https://github.com/LeoVasanko/aeg.git
cd pyaegis cd aeg
``` ```
If you already cloned without `--recursive`, initialize submodules: If you already cloned without `--recursive`, initialize submodules:
@@ -74,16 +74,10 @@ This creates files in the `dist/` directory.
## Code Generation ## Code Generation
The Python modules are generated from templates. If you modify the core implementation in `pyaegis/aegis256x4.py`, regenerate the other variants: The Python modules and CFFI definitions are generated from C sources and templates. If you modify the core implementation in `src/aeg/aegis256x4.py` or update libaegis headers, regenerate all files:
```fish ```fish
python tools/gen_modules.py python tools/generate.py
```
If you update libaegis headers, regenerate the CFFI definitions:
```fish
python tools/gen_cdef.py
``` ```
## Troubleshooting ## Troubleshooting
@@ -106,7 +100,7 @@ If you cannot install Zig, you may manually compile in the libaegis folder (Zig,
## Project Structure ## Project Structure
- `pyaegis/` - Python package source - `src/aeg/` - Python package source
- `libaegis/` - C library source (submodule) - `libaegis/` - C library source (submodule)
- `tests/` - Test suite - `tests/` - Test suite
- `tools/` - Code generation scripts and `build_backend.py` used to build libaegis - `tools/` - Code generation scripts and `build_backend.py` used to build libaegis
+1 -3
View File
@@ -1,13 +1,11 @@
include pyaegis/aegis_cdef.h include src/aeg/aegis_cdef.h
include setup.py include setup.py
include tools/build_backend.py include tools/build_backend.py
include BUILD.md include BUILD.md
include README.md include README.md
recursive-include libaegis *.c *.h *.zig *.zon recursive-include libaegis *.c *.h *.zig *.zon
include libaegis/CMakeLists.txt
include libaegis/LICENSE include libaegis/LICENSE
include libaegis/README.md include libaegis/README.md
recursive-include libaegis/cmake *.cmake *.cmake.in
graft libaegis/src graft libaegis/src
include libaegis/build.zig include libaegis/build.zig
include libaegis/build.zig.zon include libaegis/build.zig.zon
+164 -83
View File
@@ -1,33 +1,28 @@
# pyaegis # AEGIS Cipher Python Binding
[![PyPI version](https://badge.fury.io/py/pyaegis.svg)](https://badge.fury.io/py/pyaegis) [![PyPI version](https://badge.fury.io/py/aeg.svg)](https://badge.fury.io/py/aeg)
Safe Python bindings for the AEGIS family of very fast authenticated encryption algorithms (via libaegis). Safe Python bindings for the AEGIS family of very fast authenticated encryption algorithms via libaegis. The module runs without compilation required on Windows, Mac and Linux (has precompiled wheels). For other platforms compilation is performed at install time.
AEGIS enables extremely fast Encryption, MAC and CSPRNG - many times faster than AES, ChaCha20 or traditional random number generators. Authenticated Encryption with Additional Data is supported with the MAC derived from the cipher state at the end, making it different from other AEADs like AES-GCM and ChaCha20-Poly1305. The whole internal state thus depends on the prior data, and it is neither Encrypt-Then-Mac nor Mac-The-Encrypt scheme when both features are used together.
## Install ## Install
Using [uv](https://docs.astral.sh/uv/getting-started/installation/): ```sh
```fish pip install aeg
uv pip install git+https://github.com/LeoVasanko/pyaegis.git
``` ```
For development builds, see BUILD.md. Or add to your project using [UV](https://docs.astral.sh/uv/getting-started/installation/):
```sh
## Variants uv add aeg
```
All submodules expose the same API; pick one for your key/nonce size and platform:
- aegis128l (16-byte key, 16-byte nonce)
- aegis256 (32-byte key, 32-byte nonce)
- aegis128x2 / aegis128x4 (multi-lane 128-bit; best throughput on SIMD-capable CPUs)
- aegis256x2 / aegis256x4 (multi-lane 256-bit)
## Quick start ## Quick start
Normal authenticated encryption using the AEGIS-128X4 algorithm: Normal authenticated encryption using the AEGIS-128X4 algorithm:
```python ```python
from pyaegis import aegis128x4 as ciph from aeg import aegis128x4 as ciph
key = ciph.random_key() # Secret key (stored securely) key = ciph.random_key() # Secret key (stored securely)
nonce = ciph.random_nonce() # Public nonce (recreated for each message) nonce = ciph.random_nonce() # Public nonce (recreated for each message)
@@ -38,18 +33,39 @@ pt = ciph.decrypt(key, nonce, ct) # Raises ValueError if anything was tampered
assert pt == msg assert pt == msg
``` ```
## Variants
All submodules expose the same API; pick one for your needs. The 256 bit variants offer maximal security and use larger key and nonce, while the 128 bit variants run slightly faster and use smaller key and nonce while still providing strong security. The MAC length does not depend on the variant. Note that the x2 and x4 variants are typically the fastest (depending on CPU) by utilizing SIMD multi-lane processing for the highest throughput.
| Variant | Key/Nonce Bytes | Notes |
|----------------|----------------:|-------------------------|
| **aegis128l** | 16 | |
| **aegis128x2** | 16 | Fastest on Intel Core |
| **aegis128x4** | 16 | Fastest on AMD and Xeon |
| **aegis256** | 32 | |
| **aegis256x2** | 32 | Fast on Intel Core |
| **aegis256x4** | 32 | Fast on AMD and Xeon |
Instead of importing the submodules, you can obtain one by its name string:
```python
import aeg
ciph = aeg.cipher("AEGIS-128X2") # Also accepts "aegis128x2" and other forms
```
## API overview ## API overview
Common parameters and returns (applies to all items below): Common parameters and returns (applies to all items below):
- key: bytes of length a.KEYBYTES - key: bytes of length ciph.KEYBYTES
- nonce: bytes of length a.NPUBBYTES (must be unique per (key, message)) - nonce: bytes of length ciph.NONCEBYTES (must be unique per message)
- message/ct: plain text or ciphertext - message/ct: plain text or ciphertext
- ad: optional associated data (authenticated, not encrypted) - ad: optional associated data (authenticated, not encrypted)
- into: optional output buffer (see below) - into: optional output buffer (see below)
- maclen: MAC tag length 16 or 32 bytes (default 16) - maclen: MAC tag length 16 or 32 bytes (default 16)
Only the first few can be positional arguments that are always provided in this order. All arguments can be passed as kwargs. The inputs can be any Buffer supporting len() (e.g. `bytes`, `bytearray`, `memoryview`). Only the first few can be positional arguments that are always provided in this order. All arguments can be passed as kwargs. The inputs can be any Buffer (e.g. `bytes`, `bytearray`, `memoryview`).
Most functions return a buffer of bytes. By default a `bytearray` of the correct size is returned. An existing buffer can be provided by `into` argument, in which case the bytes of it that were written to are returned as a memoryview. Most functions return a buffer of bytes. By default a `bytearray` of the correct size is returned. An existing buffer can be provided by `into` argument, in which case the bytes of it that were written to are returned as a memoryview.
@@ -63,45 +79,67 @@ The MAC tag is handled separately of ciphertext:
- encrypt_detached(key, nonce, message, ad=None, maclen=16, ct_into=None, mac_into=None) -> (ct, mac) - encrypt_detached(key, nonce, message, ad=None, maclen=16, ct_into=None, mac_into=None) -> (ct, mac)
- decrypt_detached(key, nonce, ct, mac, ad=None, into=None) -> plaintext - decrypt_detached(key, nonce, ct, mac, ad=None, into=None) -> plaintext
No MAC tag, vulnerable to alterations:
- encrypt_unauthenticated(key, nonce, message, into=None) -> ciphertext (testing only)
- decrypt_unauthenticated(key, nonce, ct, into=None) -> plaintext (testing only)
### Incremental AEAD ### Incremental AEAD
Stateful classes that can be used for processing the data in separate chunks: Stateful classes that can be used for processing the data in separate chunks:
- Encryptor(key, nonce, ad=None) - Encryptor(key, nonce, ad=None, maclen=16)
- update(message[, into]) -> ciphertext_chunk - update(message[, into]) -> ciphertext_chunk
- final([into], maclen=16) -> mac_tag - final([into]) -> mac_tag
- Decryptor(key, nonce, ad=None) - Decryptor(key, nonce, ad=None, maclen=16)
- update(ct_chunk[, into]) -> plaintext_chunk - update(ct_chunk[, into]) -> plaintext_chunk
- final(mac) -> None (raises ValueError on failure) - final(mac) -> raises ValueError on failure
The object releases its state and becomes unusable after final has been called.
### Message Authentication Code ### Message Authentication Code
No encryption, but prevents changes to the data without the correct key. No encryption, but prevents changes to the data without the correct key.
- mac(key, nonce, data, maclen=16, into=None) -> mac - mac(key, nonce, data, maclen=16, into=None) -> mac bytes
- Mac(key, nonce) - Mac(key, nonce, maclen=16)
- update(data) - update(data)
- final(maclen=16[, into]) -> mac - final([into]) -> mac bytes
- verify(mac) -> bool (True on success; raises ValueError on failure) - verify(mac) -> raises ValueError on failure
- digest() -> mac bytes
- hexdigest() -> mac str
- reset()
- clone() -> Mac
The `Mac` class follows the Python hashlib API for compatibility with code expecting hash objects. After calling `final()`, `digest()`, or `hexdigest()`, the Mac object becomes unusable for further `update()` operations. However, `digest()` and `hexdigest()` cache their results and can be called multiple times. Use `reset()` to clear the state and start over, or `clone()` to create a copy before finalizing.
### Keystream generation ### Keystream generation
Useful for creating pseudo random bytes as rapidly as possible. Reuse of the same (key, nonce) creates identical output. Useful for creating pseudo random bytes as rapidly as possible. Reuse of the same (key, nonce) creates identical output.
- stream(key, nonce=None, length=None, into=None) -> randombytes - stream(key, nonce=None, length=None, into=None) -> randombytes
- stream_xor(key, nonce, data, into=None) -> data XOR keystream (unauthenticated; encrypts and decrypts)
### 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)
### Random Generator (CSPRNG)
Seeded once from the OS with random key and nonce, then a cryptographically secure AEGIS keystream: each call increments the nonce, so no output block ever repeats. `random_key()` and `random_nonce()` draw from it. The into variant can directly fill Numpy integer arrays and other structures that support Buffer API, making it far faster than any alternatives (e.g. Numpy's own random module).
- random().bytes(n) -> bytearray
- random().into(buffer) -> None
### Random-access encrypted files (aeg.raf)
pread/pwrite-style access to files split into independently encrypted chunks, with optional Merkle tree integrity. Storage is pluggable: a file path, io.BytesIO, aeg.raf.FileStorage / BytesIOStorage / StreamStorage, or a custom Storage.
- raf.create(path_or_storage, key, cipher, chunk_size=65536, merkle=False) / raf.open(...)
- f.read(size, offset), f.pread(size, offset), f.write(data, offset), f.pwrite(data, offset)
- f.seek/tell/truncate/sync/close, context manager support
- f.merkle_rebuild(), f.merkle_verify(), f.root_hash, f.verify_root(expected)
- raf.probe(storage) -> RafInfo, raf.derive_master_key(key, context)
### Exceptions ### Exceptions
- Authentication failures raise ValueError. - Authentication failures raise ValueError.
@@ -115,16 +153,22 @@ Constants (per module): KEYBYTES, NPUBBYTES, ABYTES_MIN, ABYTES_MAX, RATE, ALIGN
A cryptographically secure keyed hash is produced. The example uses all zeroes for the nonce to always produce the same hash for the same key: A cryptographically secure keyed hash is produced. The example uses all zeroes for the nonce to always produce the same hash for the same key:
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), bytes(ciph.NPUBBYTES) key, nonce = ciph.random_key(), bytes(ciph.NONCEBYTES)
mac = ciph.mac(key, nonce, b"message", maclen=32) mac = ciph.mac(key, nonce, b"message", maclen=32)
print(mac) print(mac.hex())
st = ciph.Mac(key, nonce) # Alternative class-based API
st.update(b"message") a = ciph.Mac(key, nonce, maclen=32)
st.update(b"Mallory Says Hello!") a.update(b"message")
st.verify(mac) # Raises ValueError print(a.hexdigest())
# Verification
b = ciph.Mac(key, nonce, maclen=32)
b.update(b"message")
b.update(b"Mallory Says Hello!")
b.verify(mac) # Raises ValueError
``` ```
### Detached mode encryption and decryption ### Detached mode encryption and decryption
@@ -132,7 +176,7 @@ st.verify(mac) # Raises ValueError
Keeping the ciphertext, mac and ad separate. The ad represents a file header that needs to be tamper proofed. Keeping the ciphertext, mac and ad separate. The ad represents a file header that needs to be tamper proofed.
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
ct, mac = ciph.encrypt_detached(key, nonce, b"secret", ad=b"header") ct, mac = ciph.encrypt_detached(key, nonce, b"secret", ad=b"header")
@@ -148,15 +192,15 @@ ciph.wipe(pt)
Class-based interface for incremental updates is an alternative to the one-shot functions. Not to be confused with separately verified ciphertext frames (see the next example). Class-based interface for incremental updates is an alternative to the one-shot functions. Not to be confused with separately verified ciphertext frames (see the next example).
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
enc = a.Encryptor(key, nonce, ad=b"header") enc = ciph.Encryptor(key, nonce, ad=b"header", maclen=16)
c1 = enc.update(b"chunk1") c1 = enc.update(b"chunk1")
c2 = enc.update(b"chunk2") c2 = enc.update(b"chunk2")
mac = enc.final(maclen=16) mac = enc.final()
dec = a.Decryptor(key, nonce, ad=b"header") dec = ciph.Decryptor(key, nonce, ad=b"header", maclen=16)
p1 = dec.update(c1) p1 = dec.update(c1)
p2 = dec.update(c2) p2 = dec.update(c2)
dec.final(mac) # raises ValueError on failure dec.final(mac) # raises ValueError on failure
@@ -167,16 +211,17 @@ dec.final(mac) # raises ValueError on failure
It is often practical to split larger messages into frames that can be individually decrypted and verified. Because every frame needs a different key, we employ the `nonce_increment` utility function to produce sequential nonces for each frame. As for the AEGIS algorithm, each frame is a completely independent invocation. The program will each time produce a completely different random-looking encrypted.bin file. It is often practical to split larger messages into frames that can be individually decrypted and verified. Because every frame needs a different key, we employ the `nonce_increment` utility function to produce sequential nonces for each frame. As for the AEGIS algorithm, each frame is a completely independent invocation. The program will each time produce a completely different random-looking encrypted.bin file.
```python ```python
from pyaegis import aegis128x4 as ciph # Encryption settings
from aeg import aegis128x4 as ciph
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms
framebytes = 80 # In real applications 1 MiB or more is practical
maclen = ciph.MACBYTES # 16
message = bytearray(30 * b"Attack at dawn! ") message = bytearray(30 * b"Attack at dawn! ")
key = b"sixteenbyte key!" # 16 bytes secret key for aegis128* algorithms
nonce = ciph.random_nonce()
framebytes = 80 # In real applications 1 MiB or more is practical
maclen = ciph.ABYTES_MIN # 16
with open("encrypted.bin", "wb") as f: with open("encrypted.bin", "wb") as f:
f.write(nonce) # Public initial nonce sent with the ciphertext # Public initial nonce sent with the ciphertext
nonce = ciph.random_nonce()
f.write(nonce)
while message: while message:
chunk = message[:framebytes - maclen] chunk = message[:framebytes - maclen]
del message[:len(chunk)] del message[:len(chunk)]
@@ -186,15 +231,14 @@ with open("encrypted.bin", "wb") as f:
``` ```
```python ```python
from pyaegis import aegis128x4 as ciph
# Decryption needs same values as encryption # Decryption needs same values as encryption
from aeg import aegis128x4 as ciph
key = b"sixteenbyte key!" key = b"sixteenbyte key!"
framebytes = 80 framebytes = 80
maclen = ciph.ABYTES_MIN maclen = ciph.MACBYTES
with open("encrypted.bin", "rb") as f: with open("encrypted.bin", "rb") as f:
nonce = bytearray(f.read(ciph.NPUBBYTES)) nonce = bytearray(f.read(ciph.NONCEBYTES))
while True: while True:
frame = f.read(framebytes) frame = f.read(framebytes)
if not frame: if not frame:
@@ -204,16 +248,55 @@ with open("encrypted.bin", "rb") as f:
print(pt) print(pt)
``` ```
### Random generator
The stream generator is much faster than any traditional random number generator, cryptographically secure and seekable. Use `random_key()` for unpredictable output.
```python
from aeg import aegis128x4 as ciph
key = b"SeedForReplay001" # A non-random deterministic seed (16 bytes)
nonce = bytearray(ciph.NONCEBYTES) # All-zeroes nonce
# Generate multiple blocks of pseudorandom data
for i in range(5):
rand = ciph.stream(key, nonce, 10)
print(f"Block {int.from_bytes(nonce, "little")}: {rand.hex()}")
ciph.nonce_increment(nonce)
```
Note: this is seekable by converting the block number to nonce with `idx.to_bytes(ciph.NONCEBYTES, "little")`, given some fixed block size (e.g. 1 MiB).
### Preallocated output buffers (into=) ### Preallocated output buffers (into=)
For advanced use cases, the output buffer can be supplied with `into` kwarg. Any type of writable buffer with len() >= space required can be used. This includes bytearrays, memoryviews, mmap files, numpy.getbuffer etc. For advanced use cases, the output buffer can be supplied with `into` kwarg. Any type of writable buffer with a sufficient number of bytes can be used. This includes bytearrays, memoryviews, mmap files, numpy arrays etc.
A `TypeError` is raised if the buffer is too small. For convenience, the functions return a memoryview showing only the bytes actually written. A `TypeError` is raised if the buffer is too small. For convenience, the functions return a memoryview showing only the bytes actually written.
For sensitive data, `aeg.securebuf.SecureBuffer` provides memory locked against swapping and excluded from core dumps, wiped on close:
```python
from aeg.securebuf import SecureBuffer
with SecureBuffer(1024) as buf:
pt = ciph.decrypt(key, nonce, ct, into=buf)
```
Foreign arrays can be used. This example fills a Numpy array with random integers.
```python
import numpy as np
from aeg import aegis128x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce()
arr = np.empty(10, dtype=np.uint64) # Uninitialised integer array
ciph.stream(key, nonce, into=arr) # Fill with random bytes
print(arr)
```
In-place operations are supported when the input and the output point to the same location in memory. When using attached MAC tag, the input buffer needs to be sliced to correct length: In-place operations are supported when the input and the output point to the same location in memory. When using attached MAC tag, the input buffer needs to be sliced to correct length:
```python ```python
from pyaegis import aegis256x4 as ciph from aeg import aegis256x4 as ciph
key, nonce = ciph.random_key(), ciph.random_nonce() key, nonce = ciph.random_key(), ciph.random_nonce()
buf = memoryview(bytearray(1000)) # memoryview[:len] is still in the same buffer (no copy) buf = memoryview(bytearray(1000)) # memoryview[:len] is still in the same buffer (no copy)
buf[:7] = b"message" buf[:7] = b"message"
@@ -225,39 +308,33 @@ pt = ciph.decrypt(key, nonce, ct, into=buf)
print(bytes(pt)) print(bytes(pt))
``` ```
Detached and unauthenticated modes can use same size input and output (no MAC added to ciphertext). Detached encryption instead of `into` takes `ct_into` and `mac_into` separately and returns memoryviews to both. Detached mode can use same size input and output (no MAC added to ciphertext). Detached encryption instead of `into` takes `ct_into` and `mac_into` separately and returns memoryviews to both.
## Performance ## Performance
Runtime CPU feature detection selects optimized code paths (AES-NI, ARM Crypto, AVX2/AVX-512). Multi-lane variants (x2/x4) offer higher throughput on suitable CPUs. Runtime CPU feature detection selects optimized code paths (AES-NI, ARM Crypto, AVX2/AVX-512). Multi-lane variants (x2/x4) offer higher throughput on suitable CPUs.
Run the built-in benchmark to see which variant is fastest on your machine: Benchmarks using the included benchmark module, run on Intel i7-14700, linux, single core (the software is not multithreaded). Note that the results are in megabits per second, not bytes. The CPU lacks AVX-512 that makes the X4 variants faster on processors supporting it (most AMD, Xeon).
```fish ```sh
uv run -m pyaegis.benchmark uv run -m aeg.benchmark
``` AEGIS-256 103166.24 Mb/s
AEGIS-256X2 184225.50 Mb/s
Benchmarks of the Python module and the C library run on Intel i7-14700, linux, single core (the software is not multithreaded). Note that the results are in megabits per second, not bytes. The CPU lacks AVX-512 that makes the X4 variants faster on AMD hardware. AEGIS-256X4 194018.26 Mb/s
AEGIS-128L 161551.73 Mb/s
```fish AEGIS-128X2 281987.80 Mb/s
$ python -m pyaegis.benchmark AEGIS-128X4 217997.37 Mb/s
AEGIS-256 107666.56 Mb/s AEGIS-128L MAC 188886.40 Mb/s
AEGIS-256X2 191314.53 Mb/s AEGIS-128X2 MAC 306457.97 Mb/s
AEGIS-256X4 211537.44 Mb/s AEGIS-128X4 MAC 299576.59 Mb/s
AEGIS-128L 159074.08 Mb/s AEGIS-256 MAC 100914.04 Mb/s
AEGIS-128X2 307332.53 Mb/s AEGIS-256X2 MAC 190208.20 Mb/s
AEGIS-128X4 230106.70 Mb/s AEGIS-256X4 MAC 315919.87 Mb/s
AEGIS-128L MAC 206082.24 Mb/s
AEGIS-128X2 MAC 366401.20 Mb/s
AEGIS-128X4 MAC 375011.51 Mb/s
AEGIS-256 MAC 110187.03 Mb/s
AEGIS-256X2 MAC 210063.51 Mb/s
AEGIS-256X4 MAC 347406.96 Mb/s
``` ```
The Python library performance is similar to that of the C library: The Python library performance is similar to that of the C library:
```fish ```sh
$ ./libaegis/zig-out/bin/benchmark ./libaegis/zig-out/bin/benchmark
AEGIS-256 107820.86 Mb/s AEGIS-256 107820.86 Mb/s
AEGIS-256X2 205025.57 Mb/s AEGIS-256X2 205025.57 Mb/s
AEGIS-256X4 223361.81 Mb/s AEGIS-256X4 223361.81 Mb/s
@@ -271,3 +348,7 @@ AEGIS-256 MAC 116776.62 Mb/s
AEGIS-256X2 MAC 224150.04 Mb/s AEGIS-256X2 MAC 224150.04 Mb/s
AEGIS-256X4 MAC 392088.05 Mb/s AEGIS-256X4 MAC 392088.05 Mb/s
``` ```
## Alternatives
There is also a package named [pyaegis](https://github.com/jedisct1/pyaegis) on PyPI that is unrelated to this module, but that also binds to the libaegis C library. There are also a number of modules named aegis from different packages not at all related to the encryption algorithm.
Submodule libaegis updated: 4a234009c9...3992508d3c.
View File
Whitespace-only changes.
-823
View File
@@ -1,823 +0,0 @@
"""AEGIS-128L"""
# All modules are generated from aegis128l.py by tools/gen_modules.py!
# DO NOT EDIT OTHER ALGORITHM FILES MANUALLY!
import errno
import secrets
from ._loader import ffi
from ._loader import lib as _lib
from .util import Buffer, new_aligned_struct, nonce_increment, wipe
# Constants exposed as functions in C; mirror them as integers at module import time
KEYBYTES = _lib.aegis128l_keybytes()
NPUBBYTES = _lib.aegis128l_npubbytes()
ABYTES_MIN = _lib.aegis128l_abytes_min()
ABYTES_MAX = _lib.aegis128l_abytes_max()
TAILBYTES_MAX = _lib.aegis128l_tailbytes_max()
ALIGNMENT = 32
RATE = 32
def random_key() -> bytearray:
"""Generate a random key using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(KEYBYTES))
def random_nonce() -> bytearray:
"""Generate a random nonce using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(NPUBBYTES))
def _ptr(buf):
return ffi.NULL if buf is None else ffi.from_buffer(buf)
def encrypt_detached(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
ct_into: Buffer | None = None,
mac_into: Buffer | None = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]:
f"""Encrypt message with associated data, returning ciphertext and MAC separately.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
ct_into: Buffer to write ciphertext into (default: bytearray created).
mac_into: Buffer to write MAC into (default: bytearray created).
Returns:
Tuple of (ciphertext, mac)
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if ct_into is None:
c = bytearray(len(message))
else:
if len(ct_into) < len(message):
raise TypeError("ct_into length must be at least len(message)")
c = ct_into
if mac_into is None:
mac = bytearray(maclen)
else:
if len(mac_into) < maclen:
raise TypeError("mac_into length must be at least maclen")
mac = mac_into
rc = _lib.aegis128l_encrypt_detached(
ffi.from_buffer(c),
ffi.from_buffer(mac),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt detached failed: {err_name}")
return (
c if ct_into is None else memoryview(c)[: len(message)],
mac if mac_into is None else memoryview(mac)[:maclen],
) # type: ignore
def decrypt_detached(
key: Buffer,
nonce: Buffer,
ct: Buffer,
mac: Buffer,
ad: Buffer | None = None,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with detached MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
mac: The MAC to verify.
ad: Associated data (optional).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
rc = _lib.aegis128l_decrypt_detached(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(mac),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
def encrypt(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message with associated data, returning ciphertext with appended MAC.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write ciphertext+MAC into (default: bytearray created).
Returns:
Ciphertext with appended MAC as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message) + maclen)
else:
if len(into) < len(message) + maclen:
raise TypeError("into length must be at least len(message)+maclen")
out = into
rc = _lib.aegis128l_encrypt(
ffi.from_buffer(out),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt failed: {err_name}")
return out if into is None else memoryview(out)[: len(message) + maclen] # type: ignore
def decrypt(
key: Buffer,
nonce: Buffer,
ct: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with appended MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext with MAC to decrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if len(ct) < maclen:
raise TypeError("ciphertext too short for tag")
expected_out = len(ct) - maclen
if into is None:
out = bytearray(expected_out)
else:
if len(into) < expected_out:
raise TypeError(
"into length must be at least len(ciphertext_with_tag)-maclen"
)
out = into
rc = _lib.aegis128l_decrypt(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[:expected_out] # type: ignore
def stream(
key: Buffer,
nonce: Buffer | None,
length: int | None = None,
*,
into: Buffer | None = None,
) -> bytearray | Buffer:
f"""Generate a stream of pseudorandom bytes.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}, uses zeroes for nonce if None).
length: Number of bytes to generate (required if into is None).
into: Buffer to write stream into (default: bytearray created).
Returns:
Pseudorandom bytes as bytearray, or into returned directly.
Raises:
TypeError: If lengths are invalid or neither length nor into provided.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if nonce is not None and len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
if length is None:
raise TypeError("provide either into or length")
out = bytearray(length)
else:
if length is not None and len(into) < length:
raise TypeError("into length must be at least length")
out = into
_lib.aegis128l_stream(
ffi.from_buffer(out),
len(out),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: length or len(out)] # type: ignore
def encrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
message: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
into: Buffer to write ciphertext into (default: bytearray created).
Returns:
Ciphertext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message))
else:
if len(into) < len(message):
raise TypeError("into length must be at least len(message)")
out = into
_lib.aegis128l_encrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(message),
len(message),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(message)] # type: ignore
def decrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
ct: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
_lib.aegis128l_decrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
# This is missing from C API but convenient to have here
def mac(
key: Buffer,
nonce: Buffer,
data: Buffer,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Compute a MAC for the given data in one shot.
Args:
key: Key ({KEYBYTES=})
nonce: Nonce ({NPUBBYTES=})
data: Data to MAC
maclen: MAC length (16 or 32, default 16)
into: Buffer to write MAC into (default: bytearray created)
Returns:
MAC bytes as bytearray if into not provided, memoryview of into otherwise
"""
mac_state = Mac(key, nonce)
mac_state.update(data)
return mac_state.final(maclen, into)
class Mac:
"""AEGIS-128L MAC state wrapper.
Usage:
mac = Mac(key, nonce)
mac.update(data)
tag = mac.final() # defaults to 16-byte MAC
# or verify:
mac2 = Mac(key, nonce); mac2.update(data); mac2.verify(tag)
"""
__slots__ = ("_st", "_owner")
def __init__(
self,
key: Buffer,
nonce: Buffer,
_other=None,
) -> None:
f"""Initialize a MAC state with a nonce and key.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
st, owner = new_aligned_struct("aegis128l_mac_state", ALIGNMENT)
self._st = st
self._owner = owner
if _other is not None: # clone path
_lib.aegis128l_mac_state_clone(self._st, _other._st)
return
# Normal init path
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
_lib.aegis128l_mac_init(self._st, _ptr(key), _ptr(nonce))
def __deepcopy__(self) -> "Mac":
"""Return a clone of current MAC state."""
return Mac(b"", b"", _other=self)
clone = __deepcopy__
def reset(self) -> None:
"""Reset the MAC state so it can be reused with the same nonce and key."""
_lib.aegis128l_mac_reset(self._st)
def update(self, data: Buffer) -> None:
"""Absorb data into the MAC state.
Args:
data: Bytes-like object to authenticate.
Raises:
RuntimeError: If the underlying C function reports an error.
"""
rc = _lib.aegis128l_mac_update(self._st, _ptr(data), len(data))
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac update failed: {err_name}")
def final(
self,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
"""Finalize and return the MAC tag.
Args:
maclen: Tag length in bytes (16 or 32). Defaults to 16.
into: Optional buffer to write the tag into (default: bytearray created).
Returns:
The tag as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If finalization fails in the C library.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if into is None:
out = bytearray(maclen)
else:
if len(into) < maclen:
raise TypeError("into length must be at least maclen")
out = into
out_mv = memoryview(out)
rc = _lib.aegis128l_mac_final(self._st, ffi.from_buffer(out_mv), maclen)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac final failed: {err_name}")
return out if into is None else memoryview(out)[:maclen] # type: ignore
def verify(self, mac: Buffer):
"""Verify a tag for the current MAC state.
Args:
mac: The tag to verify (16 or 32 bytes).
Returns:
Only if verification succeeds.
Raises:
TypeError: If tag length is invalid.
ValueError: If verification fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis128l_mac_verify(self._st, _ptr(mac), maclen)
if rc != 0:
raise ValueError("mac verification failed")
class Encryptor:
"""Incremental encryptor.
- update(message[, into]) -> returns produced ciphertext bytes
- final([into], maclen=16) -> returns tail+tag bytes
- final_detached([ct_into], [mac_into], maclen=16) -> returns (tail_bytes, mac)
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental encryptor.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data to bind to the encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis128l_state", ALIGNMENT)
_lib.aegis128l_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total plaintext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total ciphertext bytes produced so far.
Includes update() and final() output.
"""
return self._bytes_out
def update(
self, message: Buffer, into: Buffer | None = None
) -> bytearray | memoryview:
"""Encrypt a chunk of the message.
Args:
message: Plaintext bytes to encrypt.
into: Optional destination buffer; must be >= len(message).
Returns:
The ciphertext for this chunk as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(message)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError(
"into length must be >= expected output size for this update"
)
written = ffi.new("size_t *")
rc = _lib.aegis128l_state_encrypt_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(message),
len(message),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(
f"state encrypt update failed: {err_name} written {written[0]}"
)
w = int(written[0])
assert w == expected_out
self._bytes_in += len(message)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(
self, into: Buffer | None = None, maclen: int = ABYTES_MIN
) -> bytearray | memoryview:
"""Finalize encryption, writing any remaining bytes and the tag.
Args:
into: Optional destination buffer for the tail and tag.
maclen: Tag length (16 or 32). Defaults to 16.
Returns:
A memoryview of the produced bytes (tail + tag) if into provided, bytearray slice otherwise.
Raises:
TypeError: If maclen is invalid.
RuntimeError: If the C final call fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
# Only the authentication tag is produced here; allocate exactly maclen
out = into if into is not None else bytearray(maclen)
written = ffi.new("size_t *")
rc = _lib.aegis128l_state_encrypt_final(
self._st,
ffi.from_buffer(out),
len(out),
written,
maclen,
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state encrypt final failed: {err_name}")
w = int(written[0])
if into is None:
# Only the tag bytes are returned when we allocate the buffer
assert w == maclen
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
class Decryptor:
"""Incremental decryptor.
- update(ciphertext[, into]) -> returns plaintext bytes
- final(mac) -> verifies the MAC tag
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental decryptor for detached tags.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data used during encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis128l_state", ALIGNMENT)
_lib.aegis128l_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total ciphertext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total plaintext bytes produced so far."""
return self._bytes_out
def update(self, ct: Buffer, into: Buffer | None = None) -> bytearray | memoryview:
"""Process a chunk of ciphertext.
Args:
ct: Ciphertext bytes (without MAC).
into: Optional destination buffer; must be >= len(ciphertext).
Returns:
A memoryview of the decrypted bytes for this chunk if into provided, bytearray otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(ct)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError("into length must be >= required capacity for this update")
written = ffi.new("size_t *")
rc = _lib.aegis128l_state_decrypt_detached_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(ct),
len(ct),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, len(ct)={len(ct)}"
self._bytes_in += len(ct)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None:
"""Finalize decryption by verifying the MAC tag.
Args:
mac: Tag to verify (16 or 32 bytes).
Raises:
TypeError: If tag length is invalid.
ValueError: If authentication fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis128l_state_decrypt_detached_final(
self._st, ffi.NULL, 0, ffi.NULL, _ptr(mac), maclen
)
if rc != 0:
raise ValueError("authentication failed")
def new_state():
"""Allocate and return a new aegis128l_state* with proper alignment."""
return new_aligned_struct("aegis128l_state", ALIGNMENT)
def new_mac_state():
"""Allocate and return a new aegis128l_mac_state* with proper alignment."""
return new_aligned_struct("aegis128l_mac_state", ALIGNMENT)
__all__ = [
# constants
"KEYBYTES",
"NPUBBYTES",
"ABYTES_MIN",
"ABYTES_MAX",
"TAILBYTES_MAX",
"ALIGNMENT",
"RATE",
# utility functions
"random_key",
"random_nonce",
"nonce_increment",
"wipe",
# one-shot functions
"encrypt_detached",
"decrypt_detached",
"encrypt",
"decrypt",
"stream",
"encrypt_unauthenticated",
"decrypt_unauthenticated",
"mac",
# incremental classes
"Encryptor",
"Decryptor",
"Mac",
]
-823
View File
@@ -1,823 +0,0 @@
"""AEGIS-128X2"""
# All modules are generated from aegis128x2.py by tools/gen_modules.py!
# DO NOT EDIT OTHER ALGORITHM FILES MANUALLY!
import errno
import secrets
from ._loader import ffi
from ._loader import lib as _lib
from .util import Buffer, new_aligned_struct, nonce_increment, wipe
# Constants exposed as functions in C; mirror them as integers at module import time
KEYBYTES = _lib.aegis128x2_keybytes()
NPUBBYTES = _lib.aegis128x2_npubbytes()
ABYTES_MIN = _lib.aegis128x2_abytes_min()
ABYTES_MAX = _lib.aegis128x2_abytes_max()
TAILBYTES_MAX = _lib.aegis128x2_tailbytes_max()
ALIGNMENT = 64
RATE = 64
def random_key() -> bytearray:
"""Generate a random key using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(KEYBYTES))
def random_nonce() -> bytearray:
"""Generate a random nonce using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(NPUBBYTES))
def _ptr(buf):
return ffi.NULL if buf is None else ffi.from_buffer(buf)
def encrypt_detached(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
ct_into: Buffer | None = None,
mac_into: Buffer | None = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]:
f"""Encrypt message with associated data, returning ciphertext and MAC separately.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
ct_into: Buffer to write ciphertext into (default: bytearray created).
mac_into: Buffer to write MAC into (default: bytearray created).
Returns:
Tuple of (ciphertext, mac)
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if ct_into is None:
c = bytearray(len(message))
else:
if len(ct_into) < len(message):
raise TypeError("ct_into length must be at least len(message)")
c = ct_into
if mac_into is None:
mac = bytearray(maclen)
else:
if len(mac_into) < maclen:
raise TypeError("mac_into length must be at least maclen")
mac = mac_into
rc = _lib.aegis128x2_encrypt_detached(
ffi.from_buffer(c),
ffi.from_buffer(mac),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt detached failed: {err_name}")
return (
c if ct_into is None else memoryview(c)[: len(message)],
mac if mac_into is None else memoryview(mac)[:maclen],
) # type: ignore
def decrypt_detached(
key: Buffer,
nonce: Buffer,
ct: Buffer,
mac: Buffer,
ad: Buffer | None = None,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with detached MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
mac: The MAC to verify.
ad: Associated data (optional).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
rc = _lib.aegis128x2_decrypt_detached(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(mac),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
def encrypt(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message with associated data, returning ciphertext with appended MAC.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write ciphertext+MAC into (default: bytearray created).
Returns:
Ciphertext with appended MAC as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message) + maclen)
else:
if len(into) < len(message) + maclen:
raise TypeError("into length must be at least len(message)+maclen")
out = into
rc = _lib.aegis128x2_encrypt(
ffi.from_buffer(out),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt failed: {err_name}")
return out if into is None else memoryview(out)[: len(message) + maclen] # type: ignore
def decrypt(
key: Buffer,
nonce: Buffer,
ct: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with appended MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext with MAC to decrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if len(ct) < maclen:
raise TypeError("ciphertext too short for tag")
expected_out = len(ct) - maclen
if into is None:
out = bytearray(expected_out)
else:
if len(into) < expected_out:
raise TypeError(
"into length must be at least len(ciphertext_with_tag)-maclen"
)
out = into
rc = _lib.aegis128x2_decrypt(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[:expected_out] # type: ignore
def stream(
key: Buffer,
nonce: Buffer | None,
length: int | None = None,
*,
into: Buffer | None = None,
) -> bytearray | Buffer:
f"""Generate a stream of pseudorandom bytes.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}, uses zeroes for nonce if None).
length: Number of bytes to generate (required if into is None).
into: Buffer to write stream into (default: bytearray created).
Returns:
Pseudorandom bytes as bytearray, or into returned directly.
Raises:
TypeError: If lengths are invalid or neither length nor into provided.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if nonce is not None and len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
if length is None:
raise TypeError("provide either into or length")
out = bytearray(length)
else:
if length is not None and len(into) < length:
raise TypeError("into length must be at least length")
out = into
_lib.aegis128x2_stream(
ffi.from_buffer(out),
len(out),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: length or len(out)] # type: ignore
def encrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
message: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
into: Buffer to write ciphertext into (default: bytearray created).
Returns:
Ciphertext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message))
else:
if len(into) < len(message):
raise TypeError("into length must be at least len(message)")
out = into
_lib.aegis128x2_encrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(message),
len(message),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(message)] # type: ignore
def decrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
ct: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
_lib.aegis128x2_decrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
# This is missing from C API but convenient to have here
def mac(
key: Buffer,
nonce: Buffer,
data: Buffer,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Compute a MAC for the given data in one shot.
Args:
key: Key ({KEYBYTES=})
nonce: Nonce ({NPUBBYTES=})
data: Data to MAC
maclen: MAC length (16 or 32, default 16)
into: Buffer to write MAC into (default: bytearray created)
Returns:
MAC bytes as bytearray if into not provided, memoryview of into otherwise
"""
mac_state = Mac(key, nonce)
mac_state.update(data)
return mac_state.final(maclen, into)
class Mac:
"""AEGIS-128X2 MAC state wrapper.
Usage:
mac = Mac(key, nonce)
mac.update(data)
tag = mac.final() # defaults to 16-byte MAC
# or verify:
mac2 = Mac(key, nonce); mac2.update(data); mac2.verify(tag)
"""
__slots__ = ("_st", "_owner")
def __init__(
self,
key: Buffer,
nonce: Buffer,
_other=None,
) -> None:
f"""Initialize a MAC state with a nonce and key.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
st, owner = new_aligned_struct("aegis128x2_mac_state", ALIGNMENT)
self._st = st
self._owner = owner
if _other is not None: # clone path
_lib.aegis128x2_mac_state_clone(self._st, _other._st)
return
# Normal init path
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
_lib.aegis128x2_mac_init(self._st, _ptr(key), _ptr(nonce))
def __deepcopy__(self) -> "Mac":
"""Return a clone of current MAC state."""
return Mac(b"", b"", _other=self)
clone = __deepcopy__
def reset(self) -> None:
"""Reset the MAC state so it can be reused with the same nonce and key."""
_lib.aegis128x2_mac_reset(self._st)
def update(self, data: Buffer) -> None:
"""Absorb data into the MAC state.
Args:
data: Bytes-like object to authenticate.
Raises:
RuntimeError: If the underlying C function reports an error.
"""
rc = _lib.aegis128x2_mac_update(self._st, _ptr(data), len(data))
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac update failed: {err_name}")
def final(
self,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
"""Finalize and return the MAC tag.
Args:
maclen: Tag length in bytes (16 or 32). Defaults to 16.
into: Optional buffer to write the tag into (default: bytearray created).
Returns:
The tag as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If finalization fails in the C library.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if into is None:
out = bytearray(maclen)
else:
if len(into) < maclen:
raise TypeError("into length must be at least maclen")
out = into
out_mv = memoryview(out)
rc = _lib.aegis128x2_mac_final(self._st, ffi.from_buffer(out_mv), maclen)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac final failed: {err_name}")
return out if into is None else memoryview(out)[:maclen] # type: ignore
def verify(self, mac: Buffer):
"""Verify a tag for the current MAC state.
Args:
mac: The tag to verify (16 or 32 bytes).
Returns:
Only if verification succeeds.
Raises:
TypeError: If tag length is invalid.
ValueError: If verification fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis128x2_mac_verify(self._st, _ptr(mac), maclen)
if rc != 0:
raise ValueError("mac verification failed")
class Encryptor:
"""Incremental encryptor.
- update(message[, into]) -> returns produced ciphertext bytes
- final([into], maclen=16) -> returns tail+tag bytes
- final_detached([ct_into], [mac_into], maclen=16) -> returns (tail_bytes, mac)
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental encryptor.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data to bind to the encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis128x2_state", ALIGNMENT)
_lib.aegis128x2_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total plaintext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total ciphertext bytes produced so far.
Includes update() and final() output.
"""
return self._bytes_out
def update(
self, message: Buffer, into: Buffer | None = None
) -> bytearray | memoryview:
"""Encrypt a chunk of the message.
Args:
message: Plaintext bytes to encrypt.
into: Optional destination buffer; must be >= len(message).
Returns:
The ciphertext for this chunk as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(message)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError(
"into length must be >= expected output size for this update"
)
written = ffi.new("size_t *")
rc = _lib.aegis128x2_state_encrypt_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(message),
len(message),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(
f"state encrypt update failed: {err_name} written {written[0]}"
)
w = int(written[0])
assert w == expected_out
self._bytes_in += len(message)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(
self, into: Buffer | None = None, maclen: int = ABYTES_MIN
) -> bytearray | memoryview:
"""Finalize encryption, writing any remaining bytes and the tag.
Args:
into: Optional destination buffer for the tail and tag.
maclen: Tag length (16 or 32). Defaults to 16.
Returns:
A memoryview of the produced bytes (tail + tag) if into provided, bytearray slice otherwise.
Raises:
TypeError: If maclen is invalid.
RuntimeError: If the C final call fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
# Only the authentication tag is produced here; allocate exactly maclen
out = into if into is not None else bytearray(maclen)
written = ffi.new("size_t *")
rc = _lib.aegis128x2_state_encrypt_final(
self._st,
ffi.from_buffer(out),
len(out),
written,
maclen,
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state encrypt final failed: {err_name}")
w = int(written[0])
if into is None:
# Only the tag bytes are returned when we allocate the buffer
assert w == maclen
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
class Decryptor:
"""Incremental decryptor.
- update(ciphertext[, into]) -> returns plaintext bytes
- final(mac) -> verifies the MAC tag
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental decryptor for detached tags.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data used during encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis128x2_state", ALIGNMENT)
_lib.aegis128x2_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total ciphertext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total plaintext bytes produced so far."""
return self._bytes_out
def update(self, ct: Buffer, into: Buffer | None = None) -> bytearray | memoryview:
"""Process a chunk of ciphertext.
Args:
ct: Ciphertext bytes (without MAC).
into: Optional destination buffer; must be >= len(ciphertext).
Returns:
A memoryview of the decrypted bytes for this chunk if into provided, bytearray otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(ct)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError("into length must be >= required capacity for this update")
written = ffi.new("size_t *")
rc = _lib.aegis128x2_state_decrypt_detached_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(ct),
len(ct),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, len(ct)={len(ct)}"
self._bytes_in += len(ct)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None:
"""Finalize decryption by verifying the MAC tag.
Args:
mac: Tag to verify (16 or 32 bytes).
Raises:
TypeError: If tag length is invalid.
ValueError: If authentication fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis128x2_state_decrypt_detached_final(
self._st, ffi.NULL, 0, ffi.NULL, _ptr(mac), maclen
)
if rc != 0:
raise ValueError("authentication failed")
def new_state():
"""Allocate and return a new aegis128x2_state* with proper alignment."""
return new_aligned_struct("aegis128x2_state", ALIGNMENT)
def new_mac_state():
"""Allocate and return a new aegis128x2_mac_state* with proper alignment."""
return new_aligned_struct("aegis128x2_mac_state", ALIGNMENT)
__all__ = [
# constants
"KEYBYTES",
"NPUBBYTES",
"ABYTES_MIN",
"ABYTES_MAX",
"TAILBYTES_MAX",
"ALIGNMENT",
"RATE",
# utility functions
"random_key",
"random_nonce",
"nonce_increment",
"wipe",
# one-shot functions
"encrypt_detached",
"decrypt_detached",
"encrypt",
"decrypt",
"stream",
"encrypt_unauthenticated",
"decrypt_unauthenticated",
"mac",
# incremental classes
"Encryptor",
"Decryptor",
"Mac",
]
-823
View File
@@ -1,823 +0,0 @@
"""AEGIS-128X4"""
# All modules are generated from aegis128x4.py by tools/gen_modules.py!
# DO NOT EDIT OTHER ALGORITHM FILES MANUALLY!
import errno
import secrets
from ._loader import ffi
from ._loader import lib as _lib
from .util import Buffer, new_aligned_struct, nonce_increment, wipe
# Constants exposed as functions in C; mirror them as integers at module import time
KEYBYTES = _lib.aegis128x4_keybytes()
NPUBBYTES = _lib.aegis128x4_npubbytes()
ABYTES_MIN = _lib.aegis128x4_abytes_min()
ABYTES_MAX = _lib.aegis128x4_abytes_max()
TAILBYTES_MAX = _lib.aegis128x4_tailbytes_max()
ALIGNMENT = 64
RATE = 128
def random_key() -> bytearray:
"""Generate a random key using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(KEYBYTES))
def random_nonce() -> bytearray:
"""Generate a random nonce using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(NPUBBYTES))
def _ptr(buf):
return ffi.NULL if buf is None else ffi.from_buffer(buf)
def encrypt_detached(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
ct_into: Buffer | None = None,
mac_into: Buffer | None = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]:
f"""Encrypt message with associated data, returning ciphertext and MAC separately.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
ct_into: Buffer to write ciphertext into (default: bytearray created).
mac_into: Buffer to write MAC into (default: bytearray created).
Returns:
Tuple of (ciphertext, mac)
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if ct_into is None:
c = bytearray(len(message))
else:
if len(ct_into) < len(message):
raise TypeError("ct_into length must be at least len(message)")
c = ct_into
if mac_into is None:
mac = bytearray(maclen)
else:
if len(mac_into) < maclen:
raise TypeError("mac_into length must be at least maclen")
mac = mac_into
rc = _lib.aegis128x4_encrypt_detached(
ffi.from_buffer(c),
ffi.from_buffer(mac),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt detached failed: {err_name}")
return (
c if ct_into is None else memoryview(c)[: len(message)],
mac if mac_into is None else memoryview(mac)[:maclen],
) # type: ignore
def decrypt_detached(
key: Buffer,
nonce: Buffer,
ct: Buffer,
mac: Buffer,
ad: Buffer | None = None,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with detached MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
mac: The MAC to verify.
ad: Associated data (optional).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
rc = _lib.aegis128x4_decrypt_detached(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(mac),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
def encrypt(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message with associated data, returning ciphertext with appended MAC.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write ciphertext+MAC into (default: bytearray created).
Returns:
Ciphertext with appended MAC as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message) + maclen)
else:
if len(into) < len(message) + maclen:
raise TypeError("into length must be at least len(message)+maclen")
out = into
rc = _lib.aegis128x4_encrypt(
ffi.from_buffer(out),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt failed: {err_name}")
return out if into is None else memoryview(out)[: len(message) + maclen] # type: ignore
def decrypt(
key: Buffer,
nonce: Buffer,
ct: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with appended MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext with MAC to decrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if len(ct) < maclen:
raise TypeError("ciphertext too short for tag")
expected_out = len(ct) - maclen
if into is None:
out = bytearray(expected_out)
else:
if len(into) < expected_out:
raise TypeError(
"into length must be at least len(ciphertext_with_tag)-maclen"
)
out = into
rc = _lib.aegis128x4_decrypt(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[:expected_out] # type: ignore
def stream(
key: Buffer,
nonce: Buffer | None,
length: int | None = None,
*,
into: Buffer | None = None,
) -> bytearray | Buffer:
f"""Generate a stream of pseudorandom bytes.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}, uses zeroes for nonce if None).
length: Number of bytes to generate (required if into is None).
into: Buffer to write stream into (default: bytearray created).
Returns:
Pseudorandom bytes as bytearray, or into returned directly.
Raises:
TypeError: If lengths are invalid or neither length nor into provided.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if nonce is not None and len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
if length is None:
raise TypeError("provide either into or length")
out = bytearray(length)
else:
if length is not None and len(into) < length:
raise TypeError("into length must be at least length")
out = into
_lib.aegis128x4_stream(
ffi.from_buffer(out),
len(out),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: length or len(out)] # type: ignore
def encrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
message: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
into: Buffer to write ciphertext into (default: bytearray created).
Returns:
Ciphertext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message))
else:
if len(into) < len(message):
raise TypeError("into length must be at least len(message)")
out = into
_lib.aegis128x4_encrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(message),
len(message),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(message)] # type: ignore
def decrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
ct: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
_lib.aegis128x4_decrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
# This is missing from C API but convenient to have here
def mac(
key: Buffer,
nonce: Buffer,
data: Buffer,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Compute a MAC for the given data in one shot.
Args:
key: Key ({KEYBYTES=})
nonce: Nonce ({NPUBBYTES=})
data: Data to MAC
maclen: MAC length (16 or 32, default 16)
into: Buffer to write MAC into (default: bytearray created)
Returns:
MAC bytes as bytearray if into not provided, memoryview of into otherwise
"""
mac_state = Mac(key, nonce)
mac_state.update(data)
return mac_state.final(maclen, into)
class Mac:
"""AEGIS-128X4 MAC state wrapper.
Usage:
mac = Mac(key, nonce)
mac.update(data)
tag = mac.final() # defaults to 16-byte MAC
# or verify:
mac2 = Mac(key, nonce); mac2.update(data); mac2.verify(tag)
"""
__slots__ = ("_st", "_owner")
def __init__(
self,
key: Buffer,
nonce: Buffer,
_other=None,
) -> None:
f"""Initialize a MAC state with a nonce and key.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
st, owner = new_aligned_struct("aegis128x4_mac_state", ALIGNMENT)
self._st = st
self._owner = owner
if _other is not None: # clone path
_lib.aegis128x4_mac_state_clone(self._st, _other._st)
return
# Normal init path
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
_lib.aegis128x4_mac_init(self._st, _ptr(key), _ptr(nonce))
def __deepcopy__(self) -> "Mac":
"""Return a clone of current MAC state."""
return Mac(b"", b"", _other=self)
clone = __deepcopy__
def reset(self) -> None:
"""Reset the MAC state so it can be reused with the same nonce and key."""
_lib.aegis128x4_mac_reset(self._st)
def update(self, data: Buffer) -> None:
"""Absorb data into the MAC state.
Args:
data: Bytes-like object to authenticate.
Raises:
RuntimeError: If the underlying C function reports an error.
"""
rc = _lib.aegis128x4_mac_update(self._st, _ptr(data), len(data))
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac update failed: {err_name}")
def final(
self,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
"""Finalize and return the MAC tag.
Args:
maclen: Tag length in bytes (16 or 32). Defaults to 16.
into: Optional buffer to write the tag into (default: bytearray created).
Returns:
The tag as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If finalization fails in the C library.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if into is None:
out = bytearray(maclen)
else:
if len(into) < maclen:
raise TypeError("into length must be at least maclen")
out = into
out_mv = memoryview(out)
rc = _lib.aegis128x4_mac_final(self._st, ffi.from_buffer(out_mv), maclen)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac final failed: {err_name}")
return out if into is None else memoryview(out)[:maclen] # type: ignore
def verify(self, mac: Buffer):
"""Verify a tag for the current MAC state.
Args:
mac: The tag to verify (16 or 32 bytes).
Returns:
Only if verification succeeds.
Raises:
TypeError: If tag length is invalid.
ValueError: If verification fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis128x4_mac_verify(self._st, _ptr(mac), maclen)
if rc != 0:
raise ValueError("mac verification failed")
class Encryptor:
"""Incremental encryptor.
- update(message[, into]) -> returns produced ciphertext bytes
- final([into], maclen=16) -> returns tail+tag bytes
- final_detached([ct_into], [mac_into], maclen=16) -> returns (tail_bytes, mac)
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental encryptor.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data to bind to the encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis128x4_state", ALIGNMENT)
_lib.aegis128x4_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total plaintext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total ciphertext bytes produced so far.
Includes update() and final() output.
"""
return self._bytes_out
def update(
self, message: Buffer, into: Buffer | None = None
) -> bytearray | memoryview:
"""Encrypt a chunk of the message.
Args:
message: Plaintext bytes to encrypt.
into: Optional destination buffer; must be >= len(message).
Returns:
The ciphertext for this chunk as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(message)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError(
"into length must be >= expected output size for this update"
)
written = ffi.new("size_t *")
rc = _lib.aegis128x4_state_encrypt_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(message),
len(message),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(
f"state encrypt update failed: {err_name} written {written[0]}"
)
w = int(written[0])
assert w == expected_out
self._bytes_in += len(message)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(
self, into: Buffer | None = None, maclen: int = ABYTES_MIN
) -> bytearray | memoryview:
"""Finalize encryption, writing any remaining bytes and the tag.
Args:
into: Optional destination buffer for the tail and tag.
maclen: Tag length (16 or 32). Defaults to 16.
Returns:
A memoryview of the produced bytes (tail + tag) if into provided, bytearray slice otherwise.
Raises:
TypeError: If maclen is invalid.
RuntimeError: If the C final call fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
# Only the authentication tag is produced here; allocate exactly maclen
out = into if into is not None else bytearray(maclen)
written = ffi.new("size_t *")
rc = _lib.aegis128x4_state_encrypt_final(
self._st,
ffi.from_buffer(out),
len(out),
written,
maclen,
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state encrypt final failed: {err_name}")
w = int(written[0])
if into is None:
# Only the tag bytes are returned when we allocate the buffer
assert w == maclen
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
class Decryptor:
"""Incremental decryptor.
- update(ciphertext[, into]) -> returns plaintext bytes
- final(mac) -> verifies the MAC tag
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental decryptor for detached tags.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data used during encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis128x4_state", ALIGNMENT)
_lib.aegis128x4_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total ciphertext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total plaintext bytes produced so far."""
return self._bytes_out
def update(self, ct: Buffer, into: Buffer | None = None) -> bytearray | memoryview:
"""Process a chunk of ciphertext.
Args:
ct: Ciphertext bytes (without MAC).
into: Optional destination buffer; must be >= len(ciphertext).
Returns:
A memoryview of the decrypted bytes for this chunk if into provided, bytearray otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(ct)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError("into length must be >= required capacity for this update")
written = ffi.new("size_t *")
rc = _lib.aegis128x4_state_decrypt_detached_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(ct),
len(ct),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, len(ct)={len(ct)}"
self._bytes_in += len(ct)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None:
"""Finalize decryption by verifying the MAC tag.
Args:
mac: Tag to verify (16 or 32 bytes).
Raises:
TypeError: If tag length is invalid.
ValueError: If authentication fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis128x4_state_decrypt_detached_final(
self._st, ffi.NULL, 0, ffi.NULL, _ptr(mac), maclen
)
if rc != 0:
raise ValueError("authentication failed")
def new_state():
"""Allocate and return a new aegis128x4_state* with proper alignment."""
return new_aligned_struct("aegis128x4_state", ALIGNMENT)
def new_mac_state():
"""Allocate and return a new aegis128x4_mac_state* with proper alignment."""
return new_aligned_struct("aegis128x4_mac_state", ALIGNMENT)
__all__ = [
# constants
"KEYBYTES",
"NPUBBYTES",
"ABYTES_MIN",
"ABYTES_MAX",
"TAILBYTES_MAX",
"ALIGNMENT",
"RATE",
# utility functions
"random_key",
"random_nonce",
"nonce_increment",
"wipe",
# one-shot functions
"encrypt_detached",
"decrypt_detached",
"encrypt",
"decrypt",
"stream",
"encrypt_unauthenticated",
"decrypt_unauthenticated",
"mac",
# incremental classes
"Encryptor",
"Decryptor",
"Mac",
]
-823
View File
@@ -1,823 +0,0 @@
"""AEGIS-256"""
# All modules are generated from aegis256.py by tools/gen_modules.py!
# DO NOT EDIT OTHER ALGORITHM FILES MANUALLY!
import errno
import secrets
from ._loader import ffi
from ._loader import lib as _lib
from .util import Buffer, new_aligned_struct, nonce_increment, wipe
# Constants exposed as functions in C; mirror them as integers at module import time
KEYBYTES = _lib.aegis256_keybytes()
NPUBBYTES = _lib.aegis256_npubbytes()
ABYTES_MIN = _lib.aegis256_abytes_min()
ABYTES_MAX = _lib.aegis256_abytes_max()
TAILBYTES_MAX = _lib.aegis256_tailbytes_max()
ALIGNMENT = 16
RATE = 16
def random_key() -> bytearray:
"""Generate a random key using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(KEYBYTES))
def random_nonce() -> bytearray:
"""Generate a random nonce using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(NPUBBYTES))
def _ptr(buf):
return ffi.NULL if buf is None else ffi.from_buffer(buf)
def encrypt_detached(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
ct_into: Buffer | None = None,
mac_into: Buffer | None = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]:
f"""Encrypt message with associated data, returning ciphertext and MAC separately.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
ct_into: Buffer to write ciphertext into (default: bytearray created).
mac_into: Buffer to write MAC into (default: bytearray created).
Returns:
Tuple of (ciphertext, mac)
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if ct_into is None:
c = bytearray(len(message))
else:
if len(ct_into) < len(message):
raise TypeError("ct_into length must be at least len(message)")
c = ct_into
if mac_into is None:
mac = bytearray(maclen)
else:
if len(mac_into) < maclen:
raise TypeError("mac_into length must be at least maclen")
mac = mac_into
rc = _lib.aegis256_encrypt_detached(
ffi.from_buffer(c),
ffi.from_buffer(mac),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt detached failed: {err_name}")
return (
c if ct_into is None else memoryview(c)[: len(message)],
mac if mac_into is None else memoryview(mac)[:maclen],
) # type: ignore
def decrypt_detached(
key: Buffer,
nonce: Buffer,
ct: Buffer,
mac: Buffer,
ad: Buffer | None = None,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with detached MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
mac: The MAC to verify.
ad: Associated data (optional).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
rc = _lib.aegis256_decrypt_detached(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(mac),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
def encrypt(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message with associated data, returning ciphertext with appended MAC.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write ciphertext+MAC into (default: bytearray created).
Returns:
Ciphertext with appended MAC as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message) + maclen)
else:
if len(into) < len(message) + maclen:
raise TypeError("into length must be at least len(message)+maclen")
out = into
rc = _lib.aegis256_encrypt(
ffi.from_buffer(out),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt failed: {err_name}")
return out if into is None else memoryview(out)[: len(message) + maclen] # type: ignore
def decrypt(
key: Buffer,
nonce: Buffer,
ct: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with appended MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext with MAC to decrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if len(ct) < maclen:
raise TypeError("ciphertext too short for tag")
expected_out = len(ct) - maclen
if into is None:
out = bytearray(expected_out)
else:
if len(into) < expected_out:
raise TypeError(
"into length must be at least len(ciphertext_with_tag)-maclen"
)
out = into
rc = _lib.aegis256_decrypt(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[:expected_out] # type: ignore
def stream(
key: Buffer,
nonce: Buffer | None,
length: int | None = None,
*,
into: Buffer | None = None,
) -> bytearray | Buffer:
f"""Generate a stream of pseudorandom bytes.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}, uses zeroes for nonce if None).
length: Number of bytes to generate (required if into is None).
into: Buffer to write stream into (default: bytearray created).
Returns:
Pseudorandom bytes as bytearray, or into returned directly.
Raises:
TypeError: If lengths are invalid or neither length nor into provided.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if nonce is not None and len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
if length is None:
raise TypeError("provide either into or length")
out = bytearray(length)
else:
if length is not None and len(into) < length:
raise TypeError("into length must be at least length")
out = into
_lib.aegis256_stream(
ffi.from_buffer(out),
len(out),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: length or len(out)] # type: ignore
def encrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
message: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
into: Buffer to write ciphertext into (default: bytearray created).
Returns:
Ciphertext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message))
else:
if len(into) < len(message):
raise TypeError("into length must be at least len(message)")
out = into
_lib.aegis256_encrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(message),
len(message),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(message)] # type: ignore
def decrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
ct: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
_lib.aegis256_decrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
# This is missing from C API but convenient to have here
def mac(
key: Buffer,
nonce: Buffer,
data: Buffer,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Compute a MAC for the given data in one shot.
Args:
key: Key ({KEYBYTES=})
nonce: Nonce ({NPUBBYTES=})
data: Data to MAC
maclen: MAC length (16 or 32, default 16)
into: Buffer to write MAC into (default: bytearray created)
Returns:
MAC bytes as bytearray if into not provided, memoryview of into otherwise
"""
mac_state = Mac(key, nonce)
mac_state.update(data)
return mac_state.final(maclen, into)
class Mac:
"""AEGIS-256 MAC state wrapper.
Usage:
mac = Mac(key, nonce)
mac.update(data)
tag = mac.final() # defaults to 16-byte MAC
# or verify:
mac2 = Mac(key, nonce); mac2.update(data); mac2.verify(tag)
"""
__slots__ = ("_st", "_owner")
def __init__(
self,
key: Buffer,
nonce: Buffer,
_other=None,
) -> None:
f"""Initialize a MAC state with a nonce and key.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
st, owner = new_aligned_struct("aegis256_mac_state", ALIGNMENT)
self._st = st
self._owner = owner
if _other is not None: # clone path
_lib.aegis256_mac_state_clone(self._st, _other._st)
return
# Normal init path
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
_lib.aegis256_mac_init(self._st, _ptr(key), _ptr(nonce))
def __deepcopy__(self) -> "Mac":
"""Return a clone of current MAC state."""
return Mac(b"", b"", _other=self)
clone = __deepcopy__
def reset(self) -> None:
"""Reset the MAC state so it can be reused with the same nonce and key."""
_lib.aegis256_mac_reset(self._st)
def update(self, data: Buffer) -> None:
"""Absorb data into the MAC state.
Args:
data: Bytes-like object to authenticate.
Raises:
RuntimeError: If the underlying C function reports an error.
"""
rc = _lib.aegis256_mac_update(self._st, _ptr(data), len(data))
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac update failed: {err_name}")
def final(
self,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
"""Finalize and return the MAC tag.
Args:
maclen: Tag length in bytes (16 or 32). Defaults to 16.
into: Optional buffer to write the tag into (default: bytearray created).
Returns:
The tag as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If finalization fails in the C library.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if into is None:
out = bytearray(maclen)
else:
if len(into) < maclen:
raise TypeError("into length must be at least maclen")
out = into
out_mv = memoryview(out)
rc = _lib.aegis256_mac_final(self._st, ffi.from_buffer(out_mv), maclen)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac final failed: {err_name}")
return out if into is None else memoryview(out)[:maclen] # type: ignore
def verify(self, mac: Buffer):
"""Verify a tag for the current MAC state.
Args:
mac: The tag to verify (16 or 32 bytes).
Returns:
Only if verification succeeds.
Raises:
TypeError: If tag length is invalid.
ValueError: If verification fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis256_mac_verify(self._st, _ptr(mac), maclen)
if rc != 0:
raise ValueError("mac verification failed")
class Encryptor:
"""Incremental encryptor.
- update(message[, into]) -> returns produced ciphertext bytes
- final([into], maclen=16) -> returns tail+tag bytes
- final_detached([ct_into], [mac_into], maclen=16) -> returns (tail_bytes, mac)
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental encryptor.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data to bind to the encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis256_state", ALIGNMENT)
_lib.aegis256_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total plaintext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total ciphertext bytes produced so far.
Includes update() and final() output.
"""
return self._bytes_out
def update(
self, message: Buffer, into: Buffer | None = None
) -> bytearray | memoryview:
"""Encrypt a chunk of the message.
Args:
message: Plaintext bytes to encrypt.
into: Optional destination buffer; must be >= len(message).
Returns:
The ciphertext for this chunk as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(message)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError(
"into length must be >= expected output size for this update"
)
written = ffi.new("size_t *")
rc = _lib.aegis256_state_encrypt_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(message),
len(message),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(
f"state encrypt update failed: {err_name} written {written[0]}"
)
w = int(written[0])
assert w == expected_out
self._bytes_in += len(message)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(
self, into: Buffer | None = None, maclen: int = ABYTES_MIN
) -> bytearray | memoryview:
"""Finalize encryption, writing any remaining bytes and the tag.
Args:
into: Optional destination buffer for the tail and tag.
maclen: Tag length (16 or 32). Defaults to 16.
Returns:
A memoryview of the produced bytes (tail + tag) if into provided, bytearray slice otherwise.
Raises:
TypeError: If maclen is invalid.
RuntimeError: If the C final call fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
# Only the authentication tag is produced here; allocate exactly maclen
out = into if into is not None else bytearray(maclen)
written = ffi.new("size_t *")
rc = _lib.aegis256_state_encrypt_final(
self._st,
ffi.from_buffer(out),
len(out),
written,
maclen,
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state encrypt final failed: {err_name}")
w = int(written[0])
if into is None:
# Only the tag bytes are returned when we allocate the buffer
assert w == maclen
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
class Decryptor:
"""Incremental decryptor.
- update(ciphertext[, into]) -> returns plaintext bytes
- final(mac) -> verifies the MAC tag
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental decryptor for detached tags.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data used during encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis256_state", ALIGNMENT)
_lib.aegis256_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total ciphertext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total plaintext bytes produced so far."""
return self._bytes_out
def update(self, ct: Buffer, into: Buffer | None = None) -> bytearray | memoryview:
"""Process a chunk of ciphertext.
Args:
ct: Ciphertext bytes (without MAC).
into: Optional destination buffer; must be >= len(ciphertext).
Returns:
A memoryview of the decrypted bytes for this chunk if into provided, bytearray otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(ct)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError("into length must be >= required capacity for this update")
written = ffi.new("size_t *")
rc = _lib.aegis256_state_decrypt_detached_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(ct),
len(ct),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, len(ct)={len(ct)}"
self._bytes_in += len(ct)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None:
"""Finalize decryption by verifying the MAC tag.
Args:
mac: Tag to verify (16 or 32 bytes).
Raises:
TypeError: If tag length is invalid.
ValueError: If authentication fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis256_state_decrypt_detached_final(
self._st, ffi.NULL, 0, ffi.NULL, _ptr(mac), maclen
)
if rc != 0:
raise ValueError("authentication failed")
def new_state():
"""Allocate and return a new aegis256_state* with proper alignment."""
return new_aligned_struct("aegis256_state", ALIGNMENT)
def new_mac_state():
"""Allocate and return a new aegis256_mac_state* with proper alignment."""
return new_aligned_struct("aegis256_mac_state", ALIGNMENT)
__all__ = [
# constants
"KEYBYTES",
"NPUBBYTES",
"ABYTES_MIN",
"ABYTES_MAX",
"TAILBYTES_MAX",
"ALIGNMENT",
"RATE",
# utility functions
"random_key",
"random_nonce",
"nonce_increment",
"wipe",
# one-shot functions
"encrypt_detached",
"decrypt_detached",
"encrypt",
"decrypt",
"stream",
"encrypt_unauthenticated",
"decrypt_unauthenticated",
"mac",
# incremental classes
"Encryptor",
"Decryptor",
"Mac",
]
-823
View File
@@ -1,823 +0,0 @@
"""AEGIS-256X2"""
# All modules are generated from aegis256x2.py by tools/gen_modules.py!
# DO NOT EDIT OTHER ALGORITHM FILES MANUALLY!
import errno
import secrets
from ._loader import ffi
from ._loader import lib as _lib
from .util import Buffer, new_aligned_struct, nonce_increment, wipe
# Constants exposed as functions in C; mirror them as integers at module import time
KEYBYTES = _lib.aegis256x2_keybytes()
NPUBBYTES = _lib.aegis256x2_npubbytes()
ABYTES_MIN = _lib.aegis256x2_abytes_min()
ABYTES_MAX = _lib.aegis256x2_abytes_max()
TAILBYTES_MAX = _lib.aegis256x2_tailbytes_max()
ALIGNMENT = 32
RATE = 32
def random_key() -> bytearray:
"""Generate a random key using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(KEYBYTES))
def random_nonce() -> bytearray:
"""Generate a random nonce using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(NPUBBYTES))
def _ptr(buf):
return ffi.NULL if buf is None else ffi.from_buffer(buf)
def encrypt_detached(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
ct_into: Buffer | None = None,
mac_into: Buffer | None = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]:
f"""Encrypt message with associated data, returning ciphertext and MAC separately.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
ct_into: Buffer to write ciphertext into (default: bytearray created).
mac_into: Buffer to write MAC into (default: bytearray created).
Returns:
Tuple of (ciphertext, mac)
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if ct_into is None:
c = bytearray(len(message))
else:
if len(ct_into) < len(message):
raise TypeError("ct_into length must be at least len(message)")
c = ct_into
if mac_into is None:
mac = bytearray(maclen)
else:
if len(mac_into) < maclen:
raise TypeError("mac_into length must be at least maclen")
mac = mac_into
rc = _lib.aegis256x2_encrypt_detached(
ffi.from_buffer(c),
ffi.from_buffer(mac),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt detached failed: {err_name}")
return (
c if ct_into is None else memoryview(c)[: len(message)],
mac if mac_into is None else memoryview(mac)[:maclen],
) # type: ignore
def decrypt_detached(
key: Buffer,
nonce: Buffer,
ct: Buffer,
mac: Buffer,
ad: Buffer | None = None,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with detached MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
mac: The MAC to verify.
ad: Associated data (optional).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
rc = _lib.aegis256x2_decrypt_detached(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(mac),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
def encrypt(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message with associated data, returning ciphertext with appended MAC.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write ciphertext+MAC into (default: bytearray created).
Returns:
Ciphertext with appended MAC as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message) + maclen)
else:
if len(into) < len(message) + maclen:
raise TypeError("into length must be at least len(message)+maclen")
out = into
rc = _lib.aegis256x2_encrypt(
ffi.from_buffer(out),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt failed: {err_name}")
return out if into is None else memoryview(out)[: len(message) + maclen] # type: ignore
def decrypt(
key: Buffer,
nonce: Buffer,
ct: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with appended MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext with MAC to decrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if len(ct) < maclen:
raise TypeError("ciphertext too short for tag")
expected_out = len(ct) - maclen
if into is None:
out = bytearray(expected_out)
else:
if len(into) < expected_out:
raise TypeError(
"into length must be at least len(ciphertext_with_tag)-maclen"
)
out = into
rc = _lib.aegis256x2_decrypt(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[:expected_out] # type: ignore
def stream(
key: Buffer,
nonce: Buffer | None,
length: int | None = None,
*,
into: Buffer | None = None,
) -> bytearray | Buffer:
f"""Generate a stream of pseudorandom bytes.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}, uses zeroes for nonce if None).
length: Number of bytes to generate (required if into is None).
into: Buffer to write stream into (default: bytearray created).
Returns:
Pseudorandom bytes as bytearray, or into returned directly.
Raises:
TypeError: If lengths are invalid or neither length nor into provided.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if nonce is not None and len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
if length is None:
raise TypeError("provide either into or length")
out = bytearray(length)
else:
if length is not None and len(into) < length:
raise TypeError("into length must be at least length")
out = into
_lib.aegis256x2_stream(
ffi.from_buffer(out),
len(out),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: length or len(out)] # type: ignore
def encrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
message: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
into: Buffer to write ciphertext into (default: bytearray created).
Returns:
Ciphertext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message))
else:
if len(into) < len(message):
raise TypeError("into length must be at least len(message)")
out = into
_lib.aegis256x2_encrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(message),
len(message),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(message)] # type: ignore
def decrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
ct: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
_lib.aegis256x2_decrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
# This is missing from C API but convenient to have here
def mac(
key: Buffer,
nonce: Buffer,
data: Buffer,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Compute a MAC for the given data in one shot.
Args:
key: Key ({KEYBYTES=})
nonce: Nonce ({NPUBBYTES=})
data: Data to MAC
maclen: MAC length (16 or 32, default 16)
into: Buffer to write MAC into (default: bytearray created)
Returns:
MAC bytes as bytearray if into not provided, memoryview of into otherwise
"""
mac_state = Mac(key, nonce)
mac_state.update(data)
return mac_state.final(maclen, into)
class Mac:
"""AEGIS-256X2 MAC state wrapper.
Usage:
mac = Mac(key, nonce)
mac.update(data)
tag = mac.final() # defaults to 16-byte MAC
# or verify:
mac2 = Mac(key, nonce); mac2.update(data); mac2.verify(tag)
"""
__slots__ = ("_st", "_owner")
def __init__(
self,
key: Buffer,
nonce: Buffer,
_other=None,
) -> None:
f"""Initialize a MAC state with a nonce and key.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
st, owner = new_aligned_struct("aegis256x2_mac_state", ALIGNMENT)
self._st = st
self._owner = owner
if _other is not None: # clone path
_lib.aegis256x2_mac_state_clone(self._st, _other._st)
return
# Normal init path
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
_lib.aegis256x2_mac_init(self._st, _ptr(key), _ptr(nonce))
def __deepcopy__(self) -> "Mac":
"""Return a clone of current MAC state."""
return Mac(b"", b"", _other=self)
clone = __deepcopy__
def reset(self) -> None:
"""Reset the MAC state so it can be reused with the same nonce and key."""
_lib.aegis256x2_mac_reset(self._st)
def update(self, data: Buffer) -> None:
"""Absorb data into the MAC state.
Args:
data: Bytes-like object to authenticate.
Raises:
RuntimeError: If the underlying C function reports an error.
"""
rc = _lib.aegis256x2_mac_update(self._st, _ptr(data), len(data))
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac update failed: {err_name}")
def final(
self,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
"""Finalize and return the MAC tag.
Args:
maclen: Tag length in bytes (16 or 32). Defaults to 16.
into: Optional buffer to write the tag into (default: bytearray created).
Returns:
The tag as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If finalization fails in the C library.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if into is None:
out = bytearray(maclen)
else:
if len(into) < maclen:
raise TypeError("into length must be at least maclen")
out = into
out_mv = memoryview(out)
rc = _lib.aegis256x2_mac_final(self._st, ffi.from_buffer(out_mv), maclen)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac final failed: {err_name}")
return out if into is None else memoryview(out)[:maclen] # type: ignore
def verify(self, mac: Buffer):
"""Verify a tag for the current MAC state.
Args:
mac: The tag to verify (16 or 32 bytes).
Returns:
Only if verification succeeds.
Raises:
TypeError: If tag length is invalid.
ValueError: If verification fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis256x2_mac_verify(self._st, _ptr(mac), maclen)
if rc != 0:
raise ValueError("mac verification failed")
class Encryptor:
"""Incremental encryptor.
- update(message[, into]) -> returns produced ciphertext bytes
- final([into], maclen=16) -> returns tail+tag bytes
- final_detached([ct_into], [mac_into], maclen=16) -> returns (tail_bytes, mac)
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental encryptor.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data to bind to the encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis256x2_state", ALIGNMENT)
_lib.aegis256x2_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total plaintext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total ciphertext bytes produced so far.
Includes update() and final() output.
"""
return self._bytes_out
def update(
self, message: Buffer, into: Buffer | None = None
) -> bytearray | memoryview:
"""Encrypt a chunk of the message.
Args:
message: Plaintext bytes to encrypt.
into: Optional destination buffer; must be >= len(message).
Returns:
The ciphertext for this chunk as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(message)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError(
"into length must be >= expected output size for this update"
)
written = ffi.new("size_t *")
rc = _lib.aegis256x2_state_encrypt_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(message),
len(message),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(
f"state encrypt update failed: {err_name} written {written[0]}"
)
w = int(written[0])
assert w == expected_out
self._bytes_in += len(message)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(
self, into: Buffer | None = None, maclen: int = ABYTES_MIN
) -> bytearray | memoryview:
"""Finalize encryption, writing any remaining bytes and the tag.
Args:
into: Optional destination buffer for the tail and tag.
maclen: Tag length (16 or 32). Defaults to 16.
Returns:
A memoryview of the produced bytes (tail + tag) if into provided, bytearray slice otherwise.
Raises:
TypeError: If maclen is invalid.
RuntimeError: If the C final call fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
# Only the authentication tag is produced here; allocate exactly maclen
out = into if into is not None else bytearray(maclen)
written = ffi.new("size_t *")
rc = _lib.aegis256x2_state_encrypt_final(
self._st,
ffi.from_buffer(out),
len(out),
written,
maclen,
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state encrypt final failed: {err_name}")
w = int(written[0])
if into is None:
# Only the tag bytes are returned when we allocate the buffer
assert w == maclen
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
class Decryptor:
"""Incremental decryptor.
- update(ciphertext[, into]) -> returns plaintext bytes
- final(mac) -> verifies the MAC tag
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental decryptor for detached tags.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data used during encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis256x2_state", ALIGNMENT)
_lib.aegis256x2_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total ciphertext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total plaintext bytes produced so far."""
return self._bytes_out
def update(self, ct: Buffer, into: Buffer | None = None) -> bytearray | memoryview:
"""Process a chunk of ciphertext.
Args:
ct: Ciphertext bytes (without MAC).
into: Optional destination buffer; must be >= len(ciphertext).
Returns:
A memoryview of the decrypted bytes for this chunk if into provided, bytearray otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(ct)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError("into length must be >= required capacity for this update")
written = ffi.new("size_t *")
rc = _lib.aegis256x2_state_decrypt_detached_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(ct),
len(ct),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, len(ct)={len(ct)}"
self._bytes_in += len(ct)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None:
"""Finalize decryption by verifying the MAC tag.
Args:
mac: Tag to verify (16 or 32 bytes).
Raises:
TypeError: If tag length is invalid.
ValueError: If authentication fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis256x2_state_decrypt_detached_final(
self._st, ffi.NULL, 0, ffi.NULL, _ptr(mac), maclen
)
if rc != 0:
raise ValueError("authentication failed")
def new_state():
"""Allocate and return a new aegis256x2_state* with proper alignment."""
return new_aligned_struct("aegis256x2_state", ALIGNMENT)
def new_mac_state():
"""Allocate and return a new aegis256x2_mac_state* with proper alignment."""
return new_aligned_struct("aegis256x2_mac_state", ALIGNMENT)
__all__ = [
# constants
"KEYBYTES",
"NPUBBYTES",
"ABYTES_MIN",
"ABYTES_MAX",
"TAILBYTES_MAX",
"ALIGNMENT",
"RATE",
# utility functions
"random_key",
"random_nonce",
"nonce_increment",
"wipe",
# one-shot functions
"encrypt_detached",
"decrypt_detached",
"encrypt",
"decrypt",
"stream",
"encrypt_unauthenticated",
"decrypt_unauthenticated",
"mac",
# incremental classes
"Encryptor",
"Decryptor",
"Mac",
]
-823
View File
@@ -1,823 +0,0 @@
"""AEGIS-256X4"""
# All modules are generated from aegis256x4.py by tools/gen_modules.py!
# DO NOT EDIT OTHER ALGORITHM FILES MANUALLY!
import errno
import secrets
from ._loader import ffi
from ._loader import lib as _lib
from .util import Buffer, new_aligned_struct, nonce_increment, wipe
# Constants exposed as functions in C; mirror them as integers at module import time
KEYBYTES = _lib.aegis256x4_keybytes()
NPUBBYTES = _lib.aegis256x4_npubbytes()
ABYTES_MIN = _lib.aegis256x4_abytes_min()
ABYTES_MAX = _lib.aegis256x4_abytes_max()
TAILBYTES_MAX = _lib.aegis256x4_tailbytes_max()
ALIGNMENT = 64
RATE = 64
def random_key() -> bytearray:
"""Generate a random key using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(KEYBYTES))
def random_nonce() -> bytearray:
"""Generate a random nonce using cryptographically secure random bytes."""
return bytearray(secrets.token_bytes(NPUBBYTES))
def _ptr(buf):
return ffi.NULL if buf is None else ffi.from_buffer(buf)
def encrypt_detached(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
ct_into: Buffer | None = None,
mac_into: Buffer | None = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]:
f"""Encrypt message with associated data, returning ciphertext and MAC separately.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
ct_into: Buffer to write ciphertext into (default: bytearray created).
mac_into: Buffer to write MAC into (default: bytearray created).
Returns:
Tuple of (ciphertext, mac)
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if ct_into is None:
c = bytearray(len(message))
else:
if len(ct_into) < len(message):
raise TypeError("ct_into length must be at least len(message)")
c = ct_into
if mac_into is None:
mac = bytearray(maclen)
else:
if len(mac_into) < maclen:
raise TypeError("mac_into length must be at least maclen")
mac = mac_into
rc = _lib.aegis256x4_encrypt_detached(
ffi.from_buffer(c),
ffi.from_buffer(mac),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt detached failed: {err_name}")
return (
c if ct_into is None else memoryview(c)[: len(message)],
mac if mac_into is None else memoryview(mac)[:maclen],
) # type: ignore
def decrypt_detached(
key: Buffer,
nonce: Buffer,
ct: Buffer,
mac: Buffer,
ad: Buffer | None = None,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with detached MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
mac: The MAC to verify.
ad: Associated data (optional).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
rc = _lib.aegis256x4_decrypt_detached(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(mac),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
def encrypt(
key: Buffer,
nonce: Buffer,
message: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message with associated data, returning ciphertext with appended MAC.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write ciphertext+MAC into (default: bytearray created).
Returns:
Ciphertext with appended MAC as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If encryption fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message) + maclen)
else:
if len(into) < len(message) + maclen:
raise TypeError("into length must be at least len(message)+maclen")
out = into
rc = _lib.aegis256x4_encrypt(
ffi.from_buffer(out),
maclen,
_ptr(message),
len(message),
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"encrypt failed: {err_name}")
return out if into is None else memoryview(out)[: len(message) + maclen] # type: ignore
def decrypt(
key: Buffer,
nonce: Buffer,
ct: Buffer,
ad: Buffer | None = None,
*,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext with appended MAC and associated data.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext with MAC to decrypt.
ad: Associated data (optional).
maclen: MAC length (16 or 32, default 16).
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
ValueError: If authentication fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if len(ct) < maclen:
raise TypeError("ciphertext too short for tag")
expected_out = len(ct) - maclen
if into is None:
out = bytearray(expected_out)
else:
if len(into) < expected_out:
raise TypeError(
"into length must be at least len(ciphertext_with_tag)-maclen"
)
out = into
rc = _lib.aegis256x4_decrypt(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
maclen,
_ptr(ad),
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
if rc != 0:
raise ValueError("authentication failed")
return out if into is None else memoryview(out)[:expected_out] # type: ignore
def stream(
key: Buffer,
nonce: Buffer | None,
length: int | None = None,
*,
into: Buffer | None = None,
) -> bytearray | Buffer:
f"""Generate a stream of pseudorandom bytes.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}, uses zeroes for nonce if None).
length: Number of bytes to generate (required if into is None).
into: Buffer to write stream into (default: bytearray created).
Returns:
Pseudorandom bytes as bytearray, or into returned directly.
Raises:
TypeError: If lengths are invalid or neither length nor into provided.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if nonce is not None and len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
if length is None:
raise TypeError("provide either into or length")
out = bytearray(length)
else:
if length is not None and len(into) < length:
raise TypeError("into length must be at least length")
out = into
_lib.aegis256x4_stream(
ffi.from_buffer(out),
len(out),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: length or len(out)] # type: ignore
def encrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
message: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Encrypt message without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
message: The plaintext message to encrypt.
into: Buffer to write ciphertext into (default: bytearray created).
Returns:
Ciphertext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(message))
else:
if len(into) < len(message):
raise TypeError("into length must be at least len(message)")
out = into
_lib.aegis256x4_encrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(message),
len(message),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(message)] # type: ignore
def decrypt_unauthenticated(
key: Buffer,
nonce: Buffer,
ct: Buffer,
*,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Decrypt ciphertext without authentication (for testing/debugging).
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ct: The ciphertext to decrypt.
into: Buffer to write plaintext into (default: bytearray created).
Returns:
Plaintext as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
if into is None:
out = bytearray(len(ct))
else:
if len(into) < len(ct):
raise TypeError("into length must be at least len(ciphertext)")
out = into
_lib.aegis256x4_decrypt_unauthenticated(
ffi.from_buffer(out),
_ptr(ct),
len(ct),
_ptr(nonce),
_ptr(key),
)
return out if into is None else memoryview(out)[: len(ct)] # type: ignore
# This is missing from C API but convenient to have here
def mac(
key: Buffer,
nonce: Buffer,
data: Buffer,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
f"""Compute a MAC for the given data in one shot.
Args:
key: Key ({KEYBYTES=})
nonce: Nonce ({NPUBBYTES=})
data: Data to MAC
maclen: MAC length (16 or 32, default 16)
into: Buffer to write MAC into (default: bytearray created)
Returns:
MAC bytes as bytearray if into not provided, memoryview of into otherwise
"""
mac_state = Mac(key, nonce)
mac_state.update(data)
return mac_state.final(maclen, into)
class Mac:
"""AEGIS-256X4 MAC state wrapper.
Usage:
mac = Mac(key, nonce)
mac.update(data)
tag = mac.final() # defaults to 16-byte MAC
# or verify:
mac2 = Mac(key, nonce); mac2.update(data); mac2.verify(tag)
"""
__slots__ = ("_st", "_owner")
def __init__(
self,
key: Buffer,
nonce: Buffer,
_other=None,
) -> None:
f"""Initialize a MAC state with a nonce and key.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
st, owner = new_aligned_struct("aegis256x4_mac_state", ALIGNMENT)
self._st = st
self._owner = owner
if _other is not None: # clone path
_lib.aegis256x4_mac_state_clone(self._st, _other._st)
return
# Normal init path
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
_lib.aegis256x4_mac_init(self._st, _ptr(key), _ptr(nonce))
def __deepcopy__(self) -> "Mac":
"""Return a clone of current MAC state."""
return Mac(b"", b"", _other=self)
clone = __deepcopy__
def reset(self) -> None:
"""Reset the MAC state so it can be reused with the same nonce and key."""
_lib.aegis256x4_mac_reset(self._st)
def update(self, data: Buffer) -> None:
"""Absorb data into the MAC state.
Args:
data: Bytes-like object to authenticate.
Raises:
RuntimeError: If the underlying C function reports an error.
"""
rc = _lib.aegis256x4_mac_update(self._st, _ptr(data), len(data))
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac update failed: {err_name}")
def final(
self,
maclen: int = ABYTES_MIN,
into: Buffer | None = None,
) -> bytearray | memoryview:
"""Finalize and return the MAC tag.
Args:
maclen: Tag length in bytes (16 or 32). Defaults to 16.
into: Optional buffer to write the tag into (default: bytearray created).
Returns:
The tag as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If lengths are invalid.
RuntimeError: If finalization fails in the C library.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
if into is None:
out = bytearray(maclen)
else:
if len(into) < maclen:
raise TypeError("into length must be at least maclen")
out = into
out_mv = memoryview(out)
rc = _lib.aegis256x4_mac_final(self._st, ffi.from_buffer(out_mv), maclen)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"mac final failed: {err_name}")
return out if into is None else memoryview(out)[:maclen] # type: ignore
def verify(self, mac: Buffer):
"""Verify a tag for the current MAC state.
Args:
mac: The tag to verify (16 or 32 bytes).
Returns:
Only if verification succeeds.
Raises:
TypeError: If tag length is invalid.
ValueError: If verification fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis256x4_mac_verify(self._st, _ptr(mac), maclen)
if rc != 0:
raise ValueError("mac verification failed")
class Encryptor:
"""Incremental encryptor.
- update(message[, into]) -> returns produced ciphertext bytes
- final([into], maclen=16) -> returns tail+tag bytes
- final_detached([ct_into], [mac_into], maclen=16) -> returns (tail_bytes, mac)
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental encryptor.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data to bind to the encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis256x4_state", ALIGNMENT)
_lib.aegis256x4_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total plaintext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total ciphertext bytes produced so far.
Includes update() and final() output.
"""
return self._bytes_out
def update(
self, message: Buffer, into: Buffer | None = None
) -> bytearray | memoryview:
"""Encrypt a chunk of the message.
Args:
message: Plaintext bytes to encrypt.
into: Optional destination buffer; must be >= len(message).
Returns:
The ciphertext for this chunk as bytearray if into not provided, memoryview of into otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(message)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError(
"into length must be >= expected output size for this update"
)
written = ffi.new("size_t *")
rc = _lib.aegis256x4_state_encrypt_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(message),
len(message),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(
f"state encrypt update failed: {err_name} written {written[0]}"
)
w = int(written[0])
assert w == expected_out
self._bytes_in += len(message)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(
self, into: Buffer | None = None, maclen: int = ABYTES_MIN
) -> bytearray | memoryview:
"""Finalize encryption, writing any remaining bytes and the tag.
Args:
into: Optional destination buffer for the tail and tag.
maclen: Tag length (16 or 32). Defaults to 16.
Returns:
A memoryview of the produced bytes (tail + tag) if into provided, bytearray slice otherwise.
Raises:
TypeError: If maclen is invalid.
RuntimeError: If the C final call fails.
"""
if maclen not in (16, 32):
raise TypeError("maclen must be 16 or 32")
# Only the authentication tag is produced here; allocate exactly maclen
out = into if into is not None else bytearray(maclen)
written = ffi.new("size_t *")
rc = _lib.aegis256x4_state_encrypt_final(
self._st,
ffi.from_buffer(out),
len(out),
written,
maclen,
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state encrypt final failed: {err_name}")
w = int(written[0])
if into is None:
# Only the tag bytes are returned when we allocate the buffer
assert w == maclen
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
class Decryptor:
"""Incremental decryptor.
- update(ciphertext[, into]) -> returns plaintext bytes
- final(mac) -> verifies the MAC tag
"""
__slots__ = ("_st", "_owner", "_bytes_in", "_bytes_out")
def __init__(self, key: Buffer, nonce: Buffer, ad: Buffer | None = None):
f"""Create an incremental decryptor for detached tags.
Args:
key: Key ({KEYBYTES=}).
nonce: Nonce ({NPUBBYTES=}).
ad: Associated data used during encryption (optional).
Raises:
TypeError: If key or nonce lengths are invalid.
"""
if len(key) != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
if len(nonce) != NPUBBYTES:
raise TypeError(f"nonce length must be {NPUBBYTES}")
st, owner = new_aligned_struct("aegis256x4_state", ALIGNMENT)
_lib.aegis256x4_state_init(
st,
_ptr(ad) if ad is not None else ffi.NULL,
0 if ad is None else len(ad),
_ptr(nonce),
_ptr(key),
)
self._st = st
self._owner = owner
self._bytes_in = 0
self._bytes_out = 0
@property
def bytes_in(self) -> int:
"""Total ciphertext bytes fed to update() so far."""
return self._bytes_in
@property
def bytes_out(self) -> int:
"""Total plaintext bytes produced so far."""
return self._bytes_out
def update(self, ct: Buffer, into: Buffer | None = None) -> bytearray | memoryview:
"""Process a chunk of ciphertext.
Args:
ct: Ciphertext bytes (without MAC).
into: Optional destination buffer; must be >= len(ciphertext).
Returns:
A memoryview of the decrypted bytes for this chunk if into provided, bytearray otherwise.
Raises:
TypeError: If destination buffer is too small.
RuntimeError: If the C update call fails.
"""
expected_out = len(ct)
out = into if into is not None else bytearray(expected_out)
out_mv = memoryview(out)
if len(out_mv) < expected_out:
raise TypeError("into length must be >= required capacity for this update")
written = ffi.new("size_t *")
rc = _lib.aegis256x4_state_decrypt_detached_update(
self._st,
ffi.from_buffer(out_mv),
len(out_mv),
written,
_ptr(ct),
len(ct),
)
if rc != 0:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"state decrypt update failed: {err_name}")
w = int(written[0])
assert w == expected_out, f"got {w}, expected {expected_out}, len(ct)={len(ct)}"
self._bytes_in += len(ct)
self._bytes_out += w
return out if into is None else memoryview(out)[:w] # type: ignore
def final(self, mac: Buffer) -> None:
"""Finalize decryption by verifying the MAC tag.
Args:
mac: Tag to verify (16 or 32 bytes).
Raises:
TypeError: If tag length is invalid.
ValueError: If authentication fails.
"""
maclen = len(mac)
if maclen not in (16, 32):
raise TypeError("mac length must be 16 or 32")
rc = _lib.aegis256x4_state_decrypt_detached_final(
self._st, ffi.NULL, 0, ffi.NULL, _ptr(mac), maclen
)
if rc != 0:
raise ValueError("authentication failed")
def new_state():
"""Allocate and return a new aegis256x4_state* with proper alignment."""
return new_aligned_struct("aegis256x4_state", ALIGNMENT)
def new_mac_state():
"""Allocate and return a new aegis256x4_mac_state* with proper alignment."""
return new_aligned_struct("aegis256x4_mac_state", ALIGNMENT)
__all__ = [
# constants
"KEYBYTES",
"NPUBBYTES",
"ABYTES_MIN",
"ABYTES_MAX",
"TAILBYTES_MAX",
"ALIGNMENT",
"RATE",
# utility functions
"random_key",
"random_nonce",
"nonce_increment",
"wipe",
# one-shot functions
"encrypt_detached",
"decrypt_detached",
"encrypt",
"decrypt",
"stream",
"encrypt_unauthenticated",
"decrypt_unauthenticated",
"mac",
# incremental classes
"Encryptor",
"Decryptor",
"Mac",
]
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""
Python benchmark matching src/test/benchmark.zig for all supported Aegis algorithms.
It performs two benchmarks with the same parameters as the Zig version:
- AEGIS encrypt (attached tag, maclen = ABYTES_MIN)
- AEGIS MAC (clone state pattern)
Output format and throughput units mirror the Zig benchmark (Mb/s).
"""
import os
import time
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
MSG_LEN = 16384000 # 16 000 KiB
ITERATIONS = 100
ALGORITHMS = [
("AEGIS-128L", aegis128l),
("AEGIS-128X2", aegis128x2),
("AEGIS-128X4", aegis128x4),
("AEGIS-256", aegis256),
("AEGIS-256X2", aegis256x2),
("AEGIS-256X4", aegis256x4),
]
def _random_bytes(n: int) -> bytes:
return os.urandom(n)
def bench_encrypt(alg_name: str, a) -> None:
key = _random_bytes(a.KEYBYTES)
nonce = _random_bytes(a.NPUBBYTES)
# Single buffer, as in Zig: c_out == m buffer, with tag appended
maclen = a.ABYTES_MIN
buf = bytearray(MSG_LEN + maclen)
# Initialize buffer with random data
buf[:] = _random_bytes(len(buf))
mview = memoryview(buf)[:MSG_LEN]
t0 = time.perf_counter()
for _ in range(ITERATIONS):
a.encrypt(key, nonce, mview, None, maclen=maclen, into=buf)
t1 = time.perf_counter()
# Prevent any unrealistic optimization assumptions
_ = buf[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{alg_name}\t{throughput_mbps:10.2f} Mb/s")
def bench_mac(alg_name: str, a) -> None:
key = _random_bytes(a.KEYBYTES)
nonce = _random_bytes(a.NPUBBYTES)
buf = bytearray(MSG_LEN)
buf[:] = _random_bytes(len(buf))
mac0 = a.Mac(key, nonce)
mac_out = bytearray(a.ABYTES_MAX)
t0 = time.perf_counter()
for _ in range(ITERATIONS):
mac = mac0.clone()
mac.update(buf)
mac.final(maclen=a.ABYTES_MAX, into=mac_out)
t1 = time.perf_counter()
_ = mac_out[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{alg_name} MAC\t{throughput_mbps:10.2f} Mb/s")
if __name__ == "__main__":
# aegis_init() is called in the loader at import time already
# Run encrypt benchmarks in order: 256, 256x2, 256x4, 128l, 128x2, 128x4
bench_encrypt("AEGIS-256", aegis256)
bench_encrypt("AEGIS-256X2", aegis256x2)
bench_encrypt("AEGIS-256X4", aegis256x4)
bench_encrypt("AEGIS-128L", aegis128l)
bench_encrypt("AEGIS-128X2", aegis128x2)
bench_encrypt("AEGIS-128X4", aegis128x4)
# Run MAC benchmarks in order: 128l, 128x2, 128x4, 256, 256x2, 256x4
bench_mac("AEGIS-128L", aegis128l)
bench_mac("AEGIS-128X2", aegis128x2)
bench_mac("AEGIS-128X4", aegis128x4)
bench_mac("AEGIS-256", aegis256)
bench_mac("AEGIS-256X2", aegis256x2)
bench_mac("AEGIS-256X4", aegis256x4)
-76
View File
@@ -1,76 +0,0 @@
"""Utility helpers for pyaegis.
Currently provides Python-side aligned allocation helpers that avoid relying
on libc/posix_memalign. Memory is owned by Python; C code only borrows it.
"""
from __future__ import annotations
from typing import Protocol
from ._loader import ffi
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
try:
from collections.abc import Buffer as _Buffer
class Buffer(_Buffer, Protocol): # type: ignore[misc]
def __len__(self) -> int: ...
except ImportError:
class Buffer(Protocol):
def __len__(self) -> int: ...
def __buffer__(self, flags: int) -> memoryview: ...
def aligned_address(obj) -> int:
"""Return the integer address of the start of a cffi array object."""
return int(ffi.cast("uintptr_t", ffi.addressof(obj, 0)))
def new_aligned_struct(ctype: str, alignment: int) -> tuple[object, object]:
"""Allocate memory for one instance of ``ctype`` with requested alignment.
This allocates a Python-owned unsigned char[] buffer large enough to find
an aligned start address. Returns (ptr, owner) where ptr is a ``ctype *``
and owner is the buffer object keeping the memory alive.
"""
if alignment & (alignment - 1): # Not power of two
raise ValueError("alignment must be a power of two")
size = ffi.sizeof(ctype)
base = ffi.new("unsigned char[]", size + alignment - 1)
addr = aligned_address(base)
offset = (-addr) & (alignment - 1)
aligned_uc = ffi.addressof(base, offset)
ptr = ffi.cast(f"{ctype} *", aligned_uc)
return ptr, base
def nonce_increment(nonce: Buffer) -> None:
"""Increment the nonce in place using little-endian byte order.
Useful for generating unique nonces for each consecutive message.
Args:
nonce: The nonce buffer to increment (modified in place).
"""
n = memoryview(nonce)
for i in range(len(n)):
if n[i] < 255:
n[i] += 1
return
n[i] = 0
def wipe(buffer: Buffer) -> None:
"""Set all bytes of the input buffer to zero.
Useful for securely clearing sensitive data from memory.
Args:
buffer: The buffer to wipe (modified in place).
"""
n = memoryview(buffer)
for i in range(len(n)):
n[i] = 0
+33 -13
View File
@@ -1,35 +1,55 @@
[build-system] [build-system]
requires = ["setuptools>=61.0", "cffi>=2.0.0"] requires = ["setuptools>=84.0.0", "cffi>=2.1.1", "setuptools-scm>=10.3.4"]
build-backend = "build_backend" build-backend = "build_backend"
backend-path = ["tools"] backend-path = ["tools"]
[project] [project]
name = "pyaegis" name = "aeg"
version = "0.2.0" dynamic = ["version"]
description = "Python bindings for libaegis" description = "AEGIS encryption easy to use Python binding. Wheels for major platforms."
requires-python = ">=3.10" readme = {file = "README.md", content-type = "text/markdown"}
requires-python = ">=3.12"
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Topic :: Security :: Cryptography", "Topic :: Security :: Cryptography",
] ]
dependencies = [ dependencies = [
"cffi>=2.0.0", "cffi>=2.1.1",
] ]
[project.urls] [project.urls]
Homepage = "https://github.com/aegis-aead/libaegis" Homepage = "https://git.zi.fi/LeoVasanko/aegis-python"
Repository = "https://github.com/LeoVasanko/aegis-python"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.4.2", "auditwheel>=6.8.2",
"setuptools>=80.9.0", "patchelf>=0.17; sys_platform == 'linux'",
"pytest>=9.1.1",
"ruff>=0.16.10",
"setuptools>=84.0.0",
"setuptools-scm>=10.3.4",
] ]
[tool.setuptools] [tool.setuptools]
packages = ["pyaegis"] package-dir = {"" = "src"}
packages = ["aeg"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pyaegis = ["*.h", "*.so", "*.pyd"] aeg = ["*.h"]
[tool.ruff]
# Disable formatting in Markdown files
extend-exclude = ["*.md"]
[tool.ruff.lint]
ignore = [
"RUF022", # __all__ is deliberately grouped with comments
"RUF023", # __slots__ is deliberately grouped
"SIM117", # nested with-statements are sometimes clearer
"BLE001", # blind except is intentional at CFFI callback boundaries
]
[tool.setuptools_scm]
+21 -35
View File
@@ -1,49 +1,31 @@
"""Setup script for pyaegis - builds CFFI extension linking to libaegis.a""" """Setup script for aeg - builds CFFI extension with libaegis C library."""
import sys
import sysconfig
from pathlib import Path from pathlib import Path
from cffi import FFI from cffi import FFI
from setuptools import setup from setuptools import setup
ROOT = Path(__file__).parent.resolve()
def find_libaegis(): if sys.maxsize <= 2**32:
"""Locate libaegis.a - check common locations.""" raise RuntimeError(
libaegis_paths = [ "aeg does not support 32-bit platforms; build with a 64-bit Python"
Path("libaegis/zig-out/lib/libaegis.a"), # Zig build output (repo build) )
Path("libaegis/build/libaegis.a"), # CMake build output (repo build)
Path("/usr/local/lib/libaegis.a"), # System install
Path("/usr/lib/libaegis.a"), # System install
]
for path in libaegis_paths: libaegis_static = ROOT / "libaegis/zig-out/lib" / (
if path.exists(): "aegis.lib" if sys.platform == "win32" else "libaegis.a"
print(f"Found libaegis.a at: {path.resolve()}") )
return str(path.resolve())
# Return None instead of raising - will be caught during build
return None
# Read the CDEF header
cdef_path = Path(__file__).parent / "pyaegis" / "aegis_cdef.h"
cdef_content = cdef_path.read_text(encoding="utf-8")
# Create CFFI builder
ffibuilder = FFI() ffibuilder = FFI()
ffibuilder.cdef(cdef_content) ffibuilder.cdef((ROOT / "src/aeg/aegis_cdef.h").read_text())
# Include directory for headers # Free-threaded Python does not support Limited API (abi3)
include_dirs = [] is_free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED")
libaegis_include = Path("libaegis/src/include")
if libaegis_include.exists():
include_dirs.append(str(libaegis_include.resolve()))
# Try to find libaegis.a, but don't fail if not found (build backend will build it)
libaegis_static = find_libaegis()
# Set the source
ffibuilder.set_source( ffibuilder.set_source(
"pyaegis._aegis", # module name "aeg._aegis",
""" """
#include "aegis.h" #include "aegis.h"
#include "aegis128l.h" #include "aegis128l.h"
@@ -53,11 +35,15 @@ ffibuilder.set_source(
#include "aegis256x2.h" #include "aegis256x2.h"
#include "aegis256x4.h" #include "aegis256x4.h"
""", """,
include_dirs=include_dirs, include_dirs=[str(ROOT / "libaegis/src/include")],
extra_objects=[libaegis_static] if libaegis_static else [], extra_objects=[str(libaegis_static)],
py_limited_api=not is_free_threaded,
) )
if __name__ == "__main__": if __name__ == "__main__":
setup( setup(
cffi_modules=["setup.py:ffibuilder"], cffi_modules=["setup.py:ffibuilder"],
options=(
{"bdist_wheel": {"py_limited_api": "cp312"}} if not is_free_threaded else {}
),
) )
+18
View File
@@ -0,0 +1,18 @@
import importlib
from ._ciphers import CIPHERS, CipherName
from ._typing import Cipher
__all__ = ["cipher", "CIPHERS", "Cipher", "CipherName"]
def cipher(alg: CipherName) -> Cipher:
"""Acquire a cipher module by name."""
name = alg.lower().replace("-", "")
if name == "aegis128":
name = "aegis128l" # AEGIS-128 is dead, the user meant AEGIS-128L
if not name.startswith("aegis"):
name = "aegis" + name
if name in CIPHERS.values():
return importlib.import_module(f".{name}", __package__) # type: ignore[return-value]
raise ValueError(f"Unknown algorithm {alg!r}. Valid options: {', '.join(CIPHERS)}")
+20
View File
@@ -0,0 +1,20 @@
# This file is generated by tools/generate.py. Do not edit.
from typing import Literal
CipherName = Literal[
"AEGIS-128L",
"AEGIS-128X2",
"AEGIS-128X4",
"AEGIS-256",
"AEGIS-256X2",
"AEGIS-256X4",
]
CIPHERS: dict[CipherName, str] = {
"AEGIS-128L": "aegis128l",
"AEGIS-128X2": "aegis128x2",
"AEGIS-128X4": "aegis128x4",
"AEGIS-256": "aegis256",
"AEGIS-256X2": "aegis256x2",
"AEGIS-256X4": "aegis256x4",
}
+1 -1
View File
@@ -1,6 +1,6 @@
"""Loader for libaegis CFFI extension module.""" """Loader for libaegis CFFI extension module."""
from pyaegis._aegis import ffi, lib from aeg._aegis import ffi, lib
__all__ = ["ffi", "lib"] __all__ = ["ffi", "lib"]
+169
View File
@@ -0,0 +1,169 @@
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from .random import Random
from .util import Buffer
__all__ = ["Cipher"]
class _Mac(Protocol):
def reset(self) -> None: ...
def clone(self) -> "_Mac": ...
def update(self, data: "Buffer") -> None: ...
def final(self, into: "Buffer | None" = None) -> bytearray | memoryview: ...
def digest(self) -> bytes: ...
def hexdigest(self) -> str: ...
def verify(self, mac: "Buffer") -> None: ...
class _Encryptor(Protocol):
def update(
self, message: "Buffer", into: "Buffer | None" = None
) -> bytearray | memoryview: ...
def final(self, into: "Buffer | None" = None) -> bytearray | memoryview: ...
class _Decryptor(Protocol):
def update(
self, ct: "Buffer", into: "Buffer | None" = None
) -> bytearray | memoryview: ...
def final(self, mac: "Buffer") -> None: ...
class Cipher(Protocol):
NAME: str
KEYBYTES: int
NONCEBYTES: int
MACBYTES: int
MACBYTES_LONG: int
ALIGNMENT: int
RATE: int
Mac: type[_Mac]
Encryptor: type[_Encryptor]
Decryptor: type[_Decryptor]
@staticmethod
def random() -> "Random": ...
@staticmethod
def random_key() -> bytearray: ...
@staticmethod
def random_nonce() -> bytearray: ...
@staticmethod
def encrypt_detached(
key: "Buffer",
nonce: "Buffer",
message: "Buffer",
ad: "Buffer | None" = None,
*,
maclen: int = ...,
ct_into: "Buffer | None" = None,
mac_into: "Buffer | None" = None,
) -> tuple[bytearray | memoryview, bytearray | memoryview]: ...
@staticmethod
def decrypt_detached(
key: "Buffer",
nonce: "Buffer",
ct: "Buffer",
mac: "Buffer",
ad: "Buffer | None" = None,
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def encrypt(
key: "Buffer",
nonce: "Buffer",
message: "Buffer",
ad: "Buffer | None" = None,
*,
maclen: int = ...,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def decrypt(
key: "Buffer",
nonce: "Buffer",
ct: "Buffer",
ad: "Buffer | None" = None,
*,
maclen: int = ...,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def stream(
key: "Buffer",
nonce: "Buffer | None",
length: int | None = None,
*,
into: "Buffer | None" = None,
) -> "bytearray | Buffer": ...
@staticmethod
def stream_xor(
key: "Buffer",
nonce: "Buffer",
data: "Buffer",
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def encrypt_unauthenticated( # deprecated: use stream_xor
key: "Buffer",
nonce: "Buffer",
message: "Buffer",
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def decrypt_unauthenticated( # deprecated: use stream_xor
key: "Buffer",
nonce: "Buffer",
ct: "Buffer",
*,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def mac(
key: "Buffer",
nonce: "Buffer",
data: "Buffer",
maclen: int = ...,
into: "Buffer | None" = None,
) -> bytearray | memoryview: ...
@staticmethod
def nonce_increment(nonce: "Buffer") -> None: ...
@staticmethod
def wipe(buffer: "Buffer") -> None: ...
# random-access encrypted files (thin wrappers; see aeg.raf)
RAF_ALG_ID: int
@staticmethod
def new_raf_ctx(): ...
@staticmethod
def raf_scratch_size(chunk_size: int) -> int: ...
@staticmethod
def raf_create(key: "Buffer", ctx, io, rng, config) -> None: ...
@staticmethod
def raf_open(key: "Buffer", ctx, io, rng, config) -> None: ...
@staticmethod
def raf_read(
ctx, length: int, offset: int, *, into: "Buffer | None" = None
) -> bytearray | memoryview: ...
@staticmethod
def raf_write(ctx, data: "Buffer", offset: int) -> int: ...
@staticmethod
def raf_truncate(ctx, size: int) -> None: ...
@staticmethod
def raf_size(ctx) -> int: ...
@staticmethod
def raf_sync(ctx) -> None: ...
@staticmethod
def raf_close(ctx) -> None: ...
@staticmethod
def raf_merkle_rebuild(ctx) -> None: ...
@staticmethod
def raf_merkle_verify(ctx) -> "int | None": ...
@staticmethod
def raf_merkle_commitment(ctx, hash_len: int) -> bytes: ...
+1217
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1217
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+310 -180
View File
@@ -1,16 +1,13 @@
/* 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;
/* aegis.h */ /* aegis.h */
int aegis_init(void); int aegis_init(void);
int aegis_verify_16(const uint8_t *x, const uint8_t *y) ; int aegis_verify_16(const uint8_t *x, const uint8_t *y);
int aegis_verify_32(const uint8_t *x, const uint8_t *y) ; int aegis_verify_32(const uint8_t *x, const uint8_t *y);
/* aegis128l.h */ /* aegis128l.h */
typedef struct aegis128l_state { ...; } aegis128l_state; typedef struct aegis128l_state { uint8_t opaque[256]; } aegis128l_state;
typedef struct aegis128l_mac_state { ...; } aegis128l_mac_state; typedef struct aegis128l_mac_state { uint8_t opaque[384]; } aegis128l_mac_state;
size_t aegis128l_keybytes(void); size_t aegis128l_keybytes(void);
size_t aegis128l_npubbytes(void); size_t aegis128l_npubbytes(void);
size_t aegis128l_abytes_min(void); size_t aegis128l_abytes_min(void);
@@ -33,7 +30,7 @@ int aegis128l_decrypt_detached(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
int aegis128l_encrypt(uint8_t *c, int aegis128l_encrypt(uint8_t *c,
size_t maclen, size_t maclen,
const uint8_t *m, const uint8_t *m,
@@ -49,42 +46,22 @@ int aegis128l_decrypt(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
void aegis128l_state_init(aegis128l_state *st_, void aegis128l_state_init(aegis128l_state *st_,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k); const uint8_t *k);
int aegis128l_state_encrypt_update(aegis128l_state *st_, int aegis128l_state_encrypt_update(aegis128l_state *st_, uint8_t *c, const uint8_t *m, size_t mlen);
uint8_t *c, int aegis128l_state_encrypt_final(aegis128l_state *st_, uint8_t *mac, size_t maclen);
size_t clen_max, int aegis128l_state_decrypt_update(aegis128l_state *st_, uint8_t *m, const uint8_t *c, size_t clen);
size_t *written, int aegis128l_state_decrypt_final(aegis128l_state *st_, const uint8_t *mac, size_t maclen);
const uint8_t *m,
size_t mlen);
int aegis128l_state_encrypt_detached_final(aegis128l_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
uint8_t *mac,
size_t maclen);
int aegis128l_state_encrypt_final(aegis128l_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
size_t maclen);
int aegis128l_state_decrypt_detached_update(aegis128l_state *st_,
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *c,
size_t clen) ;
int aegis128l_state_decrypt_detached_final(aegis128l_state *st_,
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *mac,
size_t maclen) ;
void aegis128l_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k); void aegis128l_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k);
void aegis128l_stream_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t *npub,
const uint8_t *k);
void aegis128l_encrypt_unauthenticated(uint8_t *c, void aegis128l_encrypt_unauthenticated(uint8_t *c,
const uint8_t *m, const uint8_t *m,
size_t mlen, size_t mlen,
@@ -103,8 +80,8 @@ void aegis128l_mac_reset(aegis128l_mac_state *st_);
void aegis128l_mac_state_clone(aegis128l_mac_state *dst, const aegis128l_mac_state *src); void aegis128l_mac_state_clone(aegis128l_mac_state *dst, const aegis128l_mac_state *src);
/* aegis128x2.h */ /* aegis128x2.h */
typedef struct aegis128x2_state { ...; } aegis128x2_state; typedef struct aegis128x2_state { uint8_t opaque[448]; } aegis128x2_state;
typedef struct aegis128x2_mac_state { ...; } aegis128x2_mac_state; typedef struct aegis128x2_mac_state { uint8_t opaque[704]; } aegis128x2_mac_state;
size_t aegis128x2_keybytes(void); size_t aegis128x2_keybytes(void);
size_t aegis128x2_npubbytes(void); size_t aegis128x2_npubbytes(void);
size_t aegis128x2_abytes_min(void); size_t aegis128x2_abytes_min(void);
@@ -127,7 +104,7 @@ int aegis128x2_decrypt_detached(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
int aegis128x2_encrypt(uint8_t *c, int aegis128x2_encrypt(uint8_t *c,
size_t maclen, size_t maclen,
const uint8_t *m, const uint8_t *m,
@@ -143,7 +120,7 @@ int aegis128x2_decrypt(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
void aegis128x2_state_init(aegis128x2_state *st_, void aegis128x2_state_init(aegis128x2_state *st_,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
@@ -151,34 +128,20 @@ void aegis128x2_state_init(aegis128x2_state *st_,
const uint8_t *k); const uint8_t *k);
int aegis128x2_state_encrypt_update(aegis128x2_state *st_, int aegis128x2_state_encrypt_update(aegis128x2_state *st_,
uint8_t *c, uint8_t *c,
size_t clen_max,
size_t *written,
const uint8_t *m, const uint8_t *m,
size_t mlen); size_t mlen);
int aegis128x2_state_encrypt_detached_final(aegis128x2_state *st_, int aegis128x2_state_encrypt_final(aegis128x2_state *st_, uint8_t *mac, size_t maclen);
uint8_t *c, int aegis128x2_state_decrypt_update(aegis128x2_state *st_,
size_t clen_max,
size_t *written,
uint8_t *mac,
size_t maclen);
int aegis128x2_state_encrypt_final(aegis128x2_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
size_t maclen);
int aegis128x2_state_decrypt_detached_update(aegis128x2_state *st_,
uint8_t *m, uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *c, const uint8_t *c,
size_t clen) ; size_t clen);
int aegis128x2_state_decrypt_detached_final(aegis128x2_state *st_, int aegis128x2_state_decrypt_final(aegis128x2_state *st_, const uint8_t *mac, size_t maclen);
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *mac,
size_t maclen) ;
void aegis128x2_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k); void aegis128x2_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k);
void aegis128x2_stream_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t *npub,
const uint8_t *k);
void aegis128x2_encrypt_unauthenticated(uint8_t *c, void aegis128x2_encrypt_unauthenticated(uint8_t *c,
const uint8_t *m, const uint8_t *m,
size_t mlen, size_t mlen,
@@ -197,8 +160,8 @@ void aegis128x2_mac_reset(aegis128x2_mac_state *st_);
void aegis128x2_mac_state_clone(aegis128x2_mac_state *dst, const aegis128x2_mac_state *src); void aegis128x2_mac_state_clone(aegis128x2_mac_state *dst, const aegis128x2_mac_state *src);
/* aegis128x4.h */ /* aegis128x4.h */
typedef struct aegis128x4_state { ...; } aegis128x4_state; typedef struct aegis128x4_state { uint8_t opaque[832]; } aegis128x4_state;
typedef struct aegis128x4_mac_state { ...; } aegis128x4_mac_state; typedef struct aegis128x4_mac_state { uint8_t opaque[1344]; } aegis128x4_mac_state;
size_t aegis128x4_keybytes(void); size_t aegis128x4_keybytes(void);
size_t aegis128x4_npubbytes(void); size_t aegis128x4_npubbytes(void);
size_t aegis128x4_abytes_min(void); size_t aegis128x4_abytes_min(void);
@@ -221,7 +184,7 @@ int aegis128x4_decrypt_detached(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
int aegis128x4_encrypt(uint8_t *c, int aegis128x4_encrypt(uint8_t *c,
size_t maclen, size_t maclen,
const uint8_t *m, const uint8_t *m,
@@ -237,7 +200,7 @@ int aegis128x4_decrypt(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
void aegis128x4_state_init(aegis128x4_state *st_, void aegis128x4_state_init(aegis128x4_state *st_,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
@@ -245,34 +208,20 @@ void aegis128x4_state_init(aegis128x4_state *st_,
const uint8_t *k); const uint8_t *k);
int aegis128x4_state_encrypt_update(aegis128x4_state *st_, int aegis128x4_state_encrypt_update(aegis128x4_state *st_,
uint8_t *c, uint8_t *c,
size_t clen_max,
size_t *written,
const uint8_t *m, const uint8_t *m,
size_t mlen); size_t mlen);
int aegis128x4_state_encrypt_detached_final(aegis128x4_state *st_, int aegis128x4_state_encrypt_final(aegis128x4_state *st_, uint8_t *mac, size_t maclen);
uint8_t *c, int aegis128x4_state_decrypt_update(aegis128x4_state *st_,
size_t clen_max,
size_t *written,
uint8_t *mac,
size_t maclen);
int aegis128x4_state_encrypt_final(aegis128x4_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
size_t maclen);
int aegis128x4_state_decrypt_detached_update(aegis128x4_state *st_,
uint8_t *m, uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *c, const uint8_t *c,
size_t clen) ; size_t clen);
int aegis128x4_state_decrypt_detached_final(aegis128x4_state *st_, int aegis128x4_state_decrypt_final(aegis128x4_state *st_, const uint8_t *mac, size_t maclen);
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *mac,
size_t maclen) ;
void aegis128x4_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k); void aegis128x4_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k);
void aegis128x4_stream_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t *npub,
const uint8_t *k);
void aegis128x4_encrypt_unauthenticated(uint8_t *c, void aegis128x4_encrypt_unauthenticated(uint8_t *c,
const uint8_t *m, const uint8_t *m,
size_t mlen, size_t mlen,
@@ -291,8 +240,8 @@ void aegis128x4_mac_reset(aegis128x4_mac_state *st_);
void aegis128x4_mac_state_clone(aegis128x4_mac_state *dst, const aegis128x4_mac_state *src); void aegis128x4_mac_state_clone(aegis128x4_mac_state *dst, const aegis128x4_mac_state *src);
/* aegis256.h */ /* aegis256.h */
typedef struct aegis256_state { ...; } aegis256_state; typedef struct aegis256_state { uint8_t opaque[192]; } aegis256_state;
typedef struct aegis256_mac_state { ...; } aegis256_mac_state; typedef struct aegis256_mac_state { uint8_t opaque[288]; } aegis256_mac_state;
size_t aegis256_keybytes(void); size_t aegis256_keybytes(void);
size_t aegis256_npubbytes(void); size_t aegis256_npubbytes(void);
size_t aegis256_abytes_min(void); size_t aegis256_abytes_min(void);
@@ -315,7 +264,7 @@ int aegis256_decrypt_detached(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
int aegis256_encrypt(uint8_t *c, int aegis256_encrypt(uint8_t *c,
size_t maclen, size_t maclen,
const uint8_t *m, const uint8_t *m,
@@ -331,42 +280,22 @@ int aegis256_decrypt(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
void aegis256_state_init(aegis256_state *st_, void aegis256_state_init(aegis256_state *st_,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k); const uint8_t *k);
int aegis256_state_encrypt_update(aegis256_state *st_, int aegis256_state_encrypt_update(aegis256_state *st_, uint8_t *c, const uint8_t *m, size_t mlen);
uint8_t *c, int aegis256_state_encrypt_final(aegis256_state *st_, uint8_t *mac, size_t maclen);
size_t clen_max, int aegis256_state_decrypt_update(aegis256_state *st_, uint8_t *m, const uint8_t *c, size_t clen);
size_t *written, int aegis256_state_decrypt_final(aegis256_state *st_, const uint8_t *mac, size_t maclen);
const uint8_t *m,
size_t mlen);
int aegis256_state_encrypt_detached_final(aegis256_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
uint8_t *mac,
size_t maclen);
int aegis256_state_encrypt_final(aegis256_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
size_t maclen);
int aegis256_state_decrypt_detached_update(aegis256_state *st_,
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *c,
size_t clen) ;
int aegis256_state_decrypt_detached_final(aegis256_state *st_,
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *mac,
size_t maclen) ;
void aegis256_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k); void aegis256_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k);
void aegis256_stream_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t *npub,
const uint8_t *k);
void aegis256_encrypt_unauthenticated(uint8_t *c, void aegis256_encrypt_unauthenticated(uint8_t *c,
const uint8_t *m, const uint8_t *m,
size_t mlen, size_t mlen,
@@ -385,8 +314,8 @@ void aegis256_mac_reset(aegis256_mac_state *st_);
void aegis256_mac_state_clone(aegis256_mac_state *dst, const aegis256_mac_state *src); void aegis256_mac_state_clone(aegis256_mac_state *dst, const aegis256_mac_state *src);
/* aegis256x2.h */ /* aegis256x2.h */
typedef struct aegis256x2_state { ...; } aegis256x2_state; typedef struct aegis256x2_state { uint8_t opaque[320]; } aegis256x2_state;
typedef struct aegis256x2_mac_state { ...; } aegis256x2_mac_state; typedef struct aegis256x2_mac_state { uint8_t opaque[512]; } aegis256x2_mac_state;
size_t aegis256x2_keybytes(void); size_t aegis256x2_keybytes(void);
size_t aegis256x2_npubbytes(void); size_t aegis256x2_npubbytes(void);
size_t aegis256x2_abytes_min(void); size_t aegis256x2_abytes_min(void);
@@ -409,7 +338,7 @@ int aegis256x2_decrypt_detached(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
int aegis256x2_encrypt(uint8_t *c, int aegis256x2_encrypt(uint8_t *c,
size_t maclen, size_t maclen,
const uint8_t *m, const uint8_t *m,
@@ -425,7 +354,7 @@ int aegis256x2_decrypt(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
void aegis256x2_state_init(aegis256x2_state *st_, void aegis256x2_state_init(aegis256x2_state *st_,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
@@ -433,34 +362,20 @@ void aegis256x2_state_init(aegis256x2_state *st_,
const uint8_t *k); const uint8_t *k);
int aegis256x2_state_encrypt_update(aegis256x2_state *st_, int aegis256x2_state_encrypt_update(aegis256x2_state *st_,
uint8_t *c, uint8_t *c,
size_t clen_max,
size_t *written,
const uint8_t *m, const uint8_t *m,
size_t mlen); size_t mlen);
int aegis256x2_state_encrypt_detached_final(aegis256x2_state *st_, int aegis256x2_state_encrypt_final(aegis256x2_state *st_, uint8_t *mac, size_t maclen);
uint8_t *c, int aegis256x2_state_decrypt_update(aegis256x2_state *st_,
size_t clen_max,
size_t *written,
uint8_t *mac,
size_t maclen);
int aegis256x2_state_encrypt_final(aegis256x2_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
size_t maclen);
int aegis256x2_state_decrypt_detached_update(aegis256x2_state *st_,
uint8_t *m, uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *c, const uint8_t *c,
size_t clen) ; size_t clen);
int aegis256x2_state_decrypt_detached_final(aegis256x2_state *st_, int aegis256x2_state_decrypt_final(aegis256x2_state *st_, const uint8_t *mac, size_t maclen);
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *mac,
size_t maclen) ;
void aegis256x2_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k); void aegis256x2_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k);
void aegis256x2_stream_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t *npub,
const uint8_t *k);
void aegis256x2_encrypt_unauthenticated(uint8_t *c, void aegis256x2_encrypt_unauthenticated(uint8_t *c,
const uint8_t *m, const uint8_t *m,
size_t mlen, size_t mlen,
@@ -479,8 +394,8 @@ void aegis256x2_mac_reset(aegis256x2_mac_state *st_);
void aegis256x2_mac_state_clone(aegis256x2_mac_state *dst, const aegis256x2_mac_state *src); void aegis256x2_mac_state_clone(aegis256x2_mac_state *dst, const aegis256x2_mac_state *src);
/* aegis256x4.h */ /* aegis256x4.h */
typedef struct aegis256x4_state { ...; } aegis256x4_state; typedef struct aegis256x4_state { uint8_t opaque[576]; } aegis256x4_state;
typedef struct aegis256x4_mac_state { ...; } aegis256x4_mac_state; typedef struct aegis256x4_mac_state { uint8_t opaque[960]; } aegis256x4_mac_state;
size_t aegis256x4_keybytes(void); size_t aegis256x4_keybytes(void);
size_t aegis256x4_npubbytes(void); size_t aegis256x4_npubbytes(void);
size_t aegis256x4_abytes_min(void); size_t aegis256x4_abytes_min(void);
@@ -503,7 +418,7 @@ int aegis256x4_decrypt_detached(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
int aegis256x4_encrypt(uint8_t *c, int aegis256x4_encrypt(uint8_t *c,
size_t maclen, size_t maclen,
const uint8_t *m, const uint8_t *m,
@@ -519,7 +434,7 @@ int aegis256x4_decrypt(uint8_t *m,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
const uint8_t *npub, const uint8_t *npub,
const uint8_t *k) ; const uint8_t *k);
void aegis256x4_state_init(aegis256x4_state *st_, void aegis256x4_state_init(aegis256x4_state *st_,
const uint8_t *ad, const uint8_t *ad,
size_t adlen, size_t adlen,
@@ -527,34 +442,20 @@ void aegis256x4_state_init(aegis256x4_state *st_,
const uint8_t *k); const uint8_t *k);
int aegis256x4_state_encrypt_update(aegis256x4_state *st_, int aegis256x4_state_encrypt_update(aegis256x4_state *st_,
uint8_t *c, uint8_t *c,
size_t clen_max,
size_t *written,
const uint8_t *m, const uint8_t *m,
size_t mlen); size_t mlen);
int aegis256x4_state_encrypt_detached_final(aegis256x4_state *st_, int aegis256x4_state_encrypt_final(aegis256x4_state *st_, uint8_t *mac, size_t maclen);
uint8_t *c, int aegis256x4_state_decrypt_update(aegis256x4_state *st_,
size_t clen_max,
size_t *written,
uint8_t *mac,
size_t maclen);
int aegis256x4_state_encrypt_final(aegis256x4_state *st_,
uint8_t *c,
size_t clen_max,
size_t *written,
size_t maclen);
int aegis256x4_state_decrypt_detached_update(aegis256x4_state *st_,
uint8_t *m, uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *c, const uint8_t *c,
size_t clen) ; size_t clen);
int aegis256x4_state_decrypt_detached_final(aegis256x4_state *st_, int aegis256x4_state_decrypt_final(aegis256x4_state *st_, const uint8_t *mac, size_t maclen);
uint8_t *m,
size_t mlen_max,
size_t *written,
const uint8_t *mac,
size_t maclen) ;
void aegis256x4_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k); void aegis256x4_stream(uint8_t *out, size_t len, const uint8_t *npub, const uint8_t *k);
void aegis256x4_stream_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t *npub,
const uint8_t *k);
void aegis256x4_encrypt_unauthenticated(uint8_t *c, void aegis256x4_encrypt_unauthenticated(uint8_t *c,
const uint8_t *m, const uint8_t *m,
size_t mlen, size_t mlen,
@@ -571,3 +472,232 @@ int aegis256x4_mac_final(aegis256x4_mac_state *st_, uint8_t *mac, size_t maclen)
int aegis256x4_mac_verify(aegis256x4_mac_state *st_, const uint8_t *mac, size_t maclen); int aegis256x4_mac_verify(aegis256x4_mac_state *st_, const uint8_t *mac, size_t maclen);
void aegis256x4_mac_reset(aegis256x4_mac_state *st_); void aegis256x4_mac_reset(aegis256x4_mac_state *st_);
void aegis256x4_mac_state_clone(aegis256x4_mac_state *dst, const aegis256x4_mac_state *src); void aegis256x4_mac_state_clone(aegis256x4_mac_state *dst, const aegis256x4_mac_state *src);
/* aegis_raf.h */
typedef struct aegis_raf_scratch { uint8_t *buf; size_t len; } aegis_raf_scratch;
typedef struct aegis_raf_io { void *user; int (*read_at)(void *user,
uint8_t *buf,
size_t len,
uint64_t off); int (*write_at)(void *user,
const uint8_t *buf,
size_t len,
uint64_t off); int (*get_size)(void *user,
uint64_t *size); int (*set_size)(void *user,
uint64_t size); int (*sync)(void *user); } aegis_raf_io;
typedef struct aegis_raf_rng { void *user; int (*random)(void *user, uint8_t *out, size_t len); } aegis_raf_rng;
typedef struct aegis_raf_merkle_config { int (*hash_leaf)(void *user,
uint8_t *out,
size_t out_len,
const uint8_t *chunk,
size_t chunk_len,
uint64_t chunk_idx); int (*hash_parent)(void *user,
uint8_t *out,
size_t out_len,
const uint8_t *left,
const uint8_t *right,
uint32_t level,
uint64_t node_idx); int (*hash_empty)(void *user,
uint8_t *out,
size_t out_len,
uint32_t level,
uint64_t node_idx); int (*hash_commitment)(void *user,
uint8_t *out,
size_t out_len,
const uint8_t *structural_root,
const uint8_t *ctx,
size_t ctx_len,
uint64_t file_size); void *user; uint8_t *buf; size_t len; uint64_t max_chunks; uint32_t hash_len; } aegis_raf_merkle_config;
typedef struct aegis_raf_config { const aegis_raf_scratch *scratch; const aegis_raf_merkle_config *merkle; uint32_t chunk_size; uint8_t flags; } aegis_raf_config;
typedef struct aegis_raf_info { uint64_t file_size; uint32_t chunk_size; uint8_t alg_id; } aegis_raf_info;
typedef struct aegis128l_raf_ctx { uint8_t opaque[512]; } aegis128l_raf_ctx;
typedef struct aegis128x2_raf_ctx { uint8_t opaque[512]; } aegis128x2_raf_ctx;
typedef struct aegis128x4_raf_ctx { uint8_t opaque[512]; } aegis128x4_raf_ctx;
typedef struct aegis256_raf_ctx { uint8_t opaque[512]; } aegis256_raf_ctx;
typedef struct aegis256x2_raf_ctx { uint8_t opaque[512]; } aegis256x2_raf_ctx;
typedef struct aegis256x4_raf_ctx { uint8_t opaque[512]; } aegis256x4_raf_ctx;
size_t aegis_raf_chunk_min(void);
size_t aegis_raf_chunk_max(void);
size_t aegis_raf_header_size(void);
size_t aegis_raf_scratch_align(void);
int aegis_raf_probe(const aegis_raf_io *io, aegis_raf_info *info);
size_t aegis128l_raf_scratch_size(uint32_t chunk_size);
int aegis128l_raf_scratch_validate(const aegis_raf_scratch *scratch, uint32_t chunk_size);
size_t aegis128x2_raf_scratch_size(uint32_t chunk_size);
int aegis128x2_raf_scratch_validate(const aegis_raf_scratch *scratch, uint32_t chunk_size);
size_t aegis128x4_raf_scratch_size(uint32_t chunk_size);
int aegis128x4_raf_scratch_validate(const aegis_raf_scratch *scratch, uint32_t chunk_size);
size_t aegis256_raf_scratch_size(uint32_t chunk_size);
int aegis256_raf_scratch_validate(const aegis_raf_scratch *scratch, uint32_t chunk_size);
size_t aegis256x2_raf_scratch_size(uint32_t chunk_size);
int aegis256x2_raf_scratch_validate(const aegis_raf_scratch *scratch, uint32_t chunk_size);
size_t aegis256x4_raf_scratch_size(uint32_t chunk_size);
int aegis256x4_raf_scratch_validate(const aegis_raf_scratch *scratch, uint32_t chunk_size);
size_t aegis_raf_merkle_buffer_size(const aegis_raf_merkle_config *cfg);
int aegis_raf_derive_master_key(uint8_t *out,
size_t out_len,
const uint8_t *master_key,
size_t master_key_len,
const uint8_t *context,
size_t context_len);
int aegis128l_raf_create(aegis128l_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis128l_raf_open(aegis128l_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis128l_raf_read(aegis128l_raf_ctx *ctx,
uint8_t *out,
size_t *bytes_read,
size_t len,
uint64_t offset);
int aegis128l_raf_write(aegis128l_raf_ctx *ctx,
size_t *bytes_written,
const uint8_t *in,
size_t len,
uint64_t offset);
int aegis128l_raf_truncate(aegis128l_raf_ctx *ctx, uint64_t size);
int aegis128l_raf_get_size(const aegis128l_raf_ctx *ctx, uint64_t *size);
int aegis128l_raf_sync(aegis128l_raf_ctx *ctx);
void aegis128l_raf_close(aegis128l_raf_ctx *ctx);
int aegis128l_raf_merkle_rebuild(aegis128l_raf_ctx *ctx);
int aegis128l_raf_merkle_verify(aegis128l_raf_ctx *ctx, uint64_t *corrupted_chunk);
int aegis128l_raf_merkle_commitment(const aegis128l_raf_ctx *ctx, uint8_t *out, size_t out_len);
int aegis128x2_raf_create(aegis128x2_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis128x2_raf_open(aegis128x2_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis128x2_raf_read(aegis128x2_raf_ctx *ctx,
uint8_t *out,
size_t *bytes_read,
size_t len,
uint64_t offset);
int aegis128x2_raf_write(aegis128x2_raf_ctx *ctx,
size_t *bytes_written,
const uint8_t *in,
size_t len,
uint64_t offset);
int aegis128x2_raf_truncate(aegis128x2_raf_ctx *ctx, uint64_t size);
int aegis128x2_raf_get_size(const aegis128x2_raf_ctx *ctx, uint64_t *size);
int aegis128x2_raf_sync(aegis128x2_raf_ctx *ctx);
void aegis128x2_raf_close(aegis128x2_raf_ctx *ctx);
int aegis128x2_raf_merkle_rebuild(aegis128x2_raf_ctx *ctx);
int aegis128x2_raf_merkle_verify(aegis128x2_raf_ctx *ctx, uint64_t *corrupted_chunk);
int aegis128x2_raf_merkle_commitment(const aegis128x2_raf_ctx *ctx, uint8_t *out, size_t out_len);
int aegis128x4_raf_create(aegis128x4_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis128x4_raf_open(aegis128x4_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis128x4_raf_read(aegis128x4_raf_ctx *ctx,
uint8_t *out,
size_t *bytes_read,
size_t len,
uint64_t offset);
int aegis128x4_raf_write(aegis128x4_raf_ctx *ctx,
size_t *bytes_written,
const uint8_t *in,
size_t len,
uint64_t offset);
int aegis128x4_raf_truncate(aegis128x4_raf_ctx *ctx, uint64_t size);
int aegis128x4_raf_get_size(const aegis128x4_raf_ctx *ctx, uint64_t *size);
int aegis128x4_raf_sync(aegis128x4_raf_ctx *ctx);
void aegis128x4_raf_close(aegis128x4_raf_ctx *ctx);
int aegis128x4_raf_merkle_rebuild(aegis128x4_raf_ctx *ctx);
int aegis128x4_raf_merkle_verify(aegis128x4_raf_ctx *ctx, uint64_t *corrupted_chunk);
int aegis128x4_raf_merkle_commitment(const aegis128x4_raf_ctx *ctx, uint8_t *out, size_t out_len);
int aegis256_raf_create(aegis256_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis256_raf_open(aegis256_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis256_raf_read(aegis256_raf_ctx *ctx,
uint8_t *out,
size_t *bytes_read,
size_t len,
uint64_t offset);
int aegis256_raf_write(aegis256_raf_ctx *ctx,
size_t *bytes_written,
const uint8_t *in,
size_t len,
uint64_t offset);
int aegis256_raf_truncate(aegis256_raf_ctx *ctx, uint64_t size);
int aegis256_raf_get_size(const aegis256_raf_ctx *ctx, uint64_t *size);
int aegis256_raf_sync(aegis256_raf_ctx *ctx);
void aegis256_raf_close(aegis256_raf_ctx *ctx);
int aegis256_raf_merkle_rebuild(aegis256_raf_ctx *ctx);
int aegis256_raf_merkle_verify(aegis256_raf_ctx *ctx, uint64_t *corrupted_chunk);
int aegis256_raf_merkle_commitment(const aegis256_raf_ctx *ctx, uint8_t *out, size_t out_len);
int aegis256x2_raf_create(aegis256x2_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis256x2_raf_open(aegis256x2_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis256x2_raf_read(aegis256x2_raf_ctx *ctx,
uint8_t *out,
size_t *bytes_read,
size_t len,
uint64_t offset);
int aegis256x2_raf_write(aegis256x2_raf_ctx *ctx,
size_t *bytes_written,
const uint8_t *in,
size_t len,
uint64_t offset);
int aegis256x2_raf_truncate(aegis256x2_raf_ctx *ctx, uint64_t size);
int aegis256x2_raf_get_size(const aegis256x2_raf_ctx *ctx, uint64_t *size);
int aegis256x2_raf_sync(aegis256x2_raf_ctx *ctx);
void aegis256x2_raf_close(aegis256x2_raf_ctx *ctx);
int aegis256x2_raf_merkle_rebuild(aegis256x2_raf_ctx *ctx);
int aegis256x2_raf_merkle_verify(aegis256x2_raf_ctx *ctx, uint64_t *corrupted_chunk);
int aegis256x2_raf_merkle_commitment(const aegis256x2_raf_ctx *ctx, uint8_t *out, size_t out_len);
int aegis256x4_raf_create(aegis256x4_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis256x4_raf_open(aegis256x4_raf_ctx *ctx,
const aegis_raf_io *io,
const aegis_raf_rng *rng,
const aegis_raf_config *cfg,
const uint8_t *master_key);
int aegis256x4_raf_read(aegis256x4_raf_ctx *ctx,
uint8_t *out,
size_t *bytes_read,
size_t len,
uint64_t offset);
int aegis256x4_raf_write(aegis256x4_raf_ctx *ctx,
size_t *bytes_written,
const uint8_t *in,
size_t len,
uint64_t offset);
int aegis256x4_raf_truncate(aegis256x4_raf_ctx *ctx, uint64_t size);
int aegis256x4_raf_get_size(const aegis256x4_raf_ctx *ctx, uint64_t *size);
int aegis256x4_raf_sync(aegis256x4_raf_ctx *ctx);
void aegis256x4_raf_close(aegis256x4_raf_ctx *ctx);
int aegis256x4_raf_merkle_rebuild(aegis256x4_raf_ctx *ctx);
int aegis256x4_raf_merkle_verify(aegis256x4_raf_ctx *ctx, uint64_t *corrupted_chunk);
int aegis256x4_raf_merkle_commitment(const aegis256x4_raf_ctx *ctx, uint8_t *out, size_t out_len);
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env -S uv run
"""
Python benchmark matching src/test/benchmark.zig for all supported Aegis algorithms.
It performs two benchmarks with the same parameters as the Zig version:
- AEGIS encrypt (attached tag, maclen = MACBYTES)
- AEGIS MAC (clone state pattern)
Output format and throughput units mirror the Zig benchmark (Mb/s).
Runnable directly (./src/aeg/benchmark.py) or as python -m aeg.benchmark.
"""
# Direct execution puts this package's directory on sys.path, where our
# random.py would shadow the stdlib random module — drop it before imports.
import sys
from pathlib import Path
_pkg_dir = str(Path(__file__).resolve().parent)
if sys.path and Path(sys.path[0]).resolve() == Path(_pkg_dir):
sys.path.pop(0)
import secrets
import time
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
MSG_LEN = 16384000 # 16 000 KiB
ITERATIONS = 100
def bench_encrypt(ciph) -> None:
key = ciph.random_key()
nonce = ciph.random_nonce()
# Single buffer, as in Zig: c_out == m buffer, with tag appended
maclen = ciph.MACBYTES
buf = bytearray(MSG_LEN + maclen)
# Initialize buffer with random data
buf[:] = secrets.token_bytes(len(buf))
mview = memoryview(buf)[:MSG_LEN]
t0 = time.perf_counter()
for _ in range(ITERATIONS):
ciph.encrypt(key, nonce, mview, None, maclen=maclen, into=buf)
t1 = time.perf_counter()
# Prevent any unrealistic optimization assumptions
_ = buf[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{ciph.NAME}\t{throughput_mbps:10.2f} Mb/s")
def bench_mac(ciph) -> None:
key = ciph.random_key()
nonce = ciph.random_nonce()
buf = bytearray(MSG_LEN)
buf[:] = secrets.token_bytes(len(buf))
mac_out = bytearray(ciph.MACBYTES_LONG)
t0 = time.perf_counter()
for _ in range(ITERATIONS):
ciph.mac(key, nonce, buf, maclen=ciph.MACBYTES_LONG, into=mac_out)
t1 = time.perf_counter()
_ = mac_out[0]
bits = MSG_LEN * ITERATIONS * 8
elapsed_s = t1 - t0
throughput_mbps = (
(bits / (elapsed_s * 1_000_000)) if elapsed_s > 0 else float("inf")
)
print(f"{ciph.NAME} MAC\t{throughput_mbps:10.2f} Mb/s")
if __name__ == "__main__":
# aegis_init() is called in the loader at import time already
# Run encrypt benchmarks in order: 256, 256x2, 256x4, 128l, 128x2, 128x4
bench_encrypt(aegis256)
bench_encrypt(aegis256x2)
bench_encrypt(aegis256x4)
bench_encrypt(aegis128l)
bench_encrypt(aegis128x2)
bench_encrypt(aegis128x4)
# Run MAC benchmarks in order: 128l, 128x2, 128x4, 256, 256x2, 256x4
bench_mac(aegis128l)
bench_mac(aegis128x2)
bench_mac(aegis128x4)
bench_mac(aegis256)
bench_mac(aegis256x2)
bench_mac(aegis256x4)
+981
View File
@@ -0,0 +1,981 @@
"""RAF (random-access encrypted file) high-level API.
Provides pread/pwrite-style access to encrypted files. Files are divided into
fixed-size chunks, each independently encrypted with a fresh nonce, enabling
efficient random access without decrypting the whole file.
Use create() / open() with a cipher module (from aeg.cipher()) and a path,
an io.BytesIO, or a Storage instance.
Portions of the storage/callback/Merkle/file-like machinery are adapted from
pyaegis (https://github.com/jedisct1/pyaegis) by Frank Denis, MIT license.
"""
from __future__ import annotations
import errno as errno_module
import hashlib
import io as io_module
import os
import threading
from typing import NamedTuple, Protocol, Self, runtime_checkable
from . import cipher as _cipher_for_name
from . import random as _random
from ._loader import ffi, lib
from ._typing import Cipher
from .util import Buffer
__all__ = [
"CHUNK_MIN",
"CHUNK_MAX",
"HEADER_SIZE",
"SCRATCH_ALIGN",
"Storage",
"FileStorage",
"BytesIOStorage",
"StreamStorage",
"MerkleHasher",
"SHA256MerkleHasher",
"RafInfo",
"Raf",
"create",
"open",
"probe",
"derive_master_key",
]
CHUNK_MIN = int(lib.aegis_raf_chunk_min()) #: Minimum chunk size in bytes
CHUNK_MAX = int(lib.aegis_raf_chunk_max()) #: Maximum chunk size in bytes
HEADER_SIZE = int(lib.aegis_raf_header_size()) #: Encrypted file header size
SCRATCH_ALIGN = int(lib.aegis_raf_scratch_align()) #: Required scratch buffer alignment
# From aegis_raf.h (preprocessor macros, not available through cdef)
_FLAG_CREATE = 0x01
_FLAG_TRUNCATE = 0x02
_MERKLE_HASH_MIN = 8
_MERKLE_HASH_MAX = 64
@runtime_checkable
class Storage(Protocol):
"""Backing storage for RAF files. Operations must complete fully or raise OSError."""
def read_at(self, buf: bytearray, offset: int) -> None:
"""Read exactly len(buf) bytes at offset into buf."""
...
def write_at(self, data: bytes, offset: int) -> None:
"""Write exactly len(data) bytes at offset."""
...
def get_size(self) -> int:
"""Return current storage size in bytes."""
...
def set_size(self, size: int) -> None:
"""Resize storage (truncate or extend)."""
...
def sync(self) -> None:
"""Flush writes to durable storage. May be a no-op."""
...
class FileStorage:
"""File-based storage.
Uses os.pread/os.pwrite where available (Unix); otherwise falls back to
a lock plus os.lseek/os.read/os.write (e.g. on Windows).
"""
def __init__(self, path: str | os.PathLike, mode: str = "r+b"):
"""Open a file for RAF storage.
Args:
path: Path to the file.
mode: "r+b" to open existing, "w+b" or "x+b" to create.
"""
if mode == "r+b":
flags = os.O_RDWR
elif mode == "w+b":
flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC
elif mode == "x+b":
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
else:
raise ValueError(f"Unsupported mode: {mode}")
self._fd = os.open(path, flags, 0o644)
self._closed = False
self._lock = threading.Lock()
self._positional = hasattr(os, "pread") and hasattr(os, "pwrite")
def read_at(self, buf: bytearray, offset: int) -> None:
"""Read exactly len(buf) bytes. Raises OSError on short read."""
view = memoryview(buf)
if self._positional:
done = 0
while done < len(view):
chunk = os.pread(self._fd, len(view) - done, offset + done)
if not chunk:
raise OSError(f"Short read: expected {len(view)}, got {done}")
view[done : done + len(chunk)] = chunk
done += len(chunk)
else:
with self._lock:
os.lseek(self._fd, offset, os.SEEK_SET)
done = 0
while done < len(view):
chunk = os.read(self._fd, len(view) - done)
if not chunk:
raise OSError(f"Short read: expected {len(view)}, got {done}")
view[done : done + len(chunk)] = chunk
done += len(chunk)
def write_at(self, data: bytes, offset: int) -> None:
"""Write exactly len(data) bytes."""
view = memoryview(data)
if self._positional:
done = 0
while done < len(view):
done += os.pwrite(self._fd, view[done:], offset + done)
else:
with self._lock:
os.lseek(self._fd, offset, os.SEEK_SET)
done = 0
while done < len(view):
done += os.write(self._fd, view[done:])
def get_size(self) -> int:
"""Return current file size."""
return os.fstat(self._fd).st_size
def set_size(self, size: int) -> None:
"""Truncate or extend the file to the given size."""
os.ftruncate(self._fd, size)
def sync(self) -> None:
"""Flush file data to disk."""
os.fsync(self._fd)
def close(self) -> None:
"""Close the underlying file descriptor."""
if not self._closed:
os.close(self._fd)
self._closed = True
def __enter__(self) -> Self:
return self
def __exit__(self, *args) -> None:
self.close()
class BytesIOStorage:
"""In-memory storage backed by a bytearray. Useful for tests."""
def __init__(self, initial: bytes = b""):
self._data = bytearray(initial)
def read_at(self, buf: bytearray, offset: int) -> None:
"""Read exactly len(buf) bytes at offset into buf."""
end = offset + len(buf)
if end > len(self._data):
raise OSError(
f"Read past EOF: offset={offset}, len={len(buf)}, size={len(self._data)}"
)
buf[:] = self._data[offset:end]
def write_at(self, data: bytes, offset: int) -> None:
"""Write exactly len(data) bytes at offset."""
if not data:
return
end = offset + len(data)
if end > len(self._data):
self._data.extend(b"\x00" * (end - len(self._data)))
self._data[offset:end] = data
def get_size(self) -> int:
"""Return current buffer size."""
return len(self._data)
def set_size(self, size: int) -> None:
"""Resize the buffer (truncate or extend with zeros)."""
current = len(self._data)
if size < current:
del self._data[size:]
elif size > current:
self._data.extend(b"\x00" * (size - current))
def sync(self) -> None:
"""No-op for in-memory storage."""
def getvalue(self) -> bytes:
"""Return the entire buffer contents."""
return bytes(self._data)
def __enter__(self) -> Self:
return self
def __exit__(self, *args) -> None:
pass
class StreamStorage:
"""Storage adapter for seek-based file-like objects (io.BytesIO, open files).
Uses a lock plus seek/read/write, mirroring FileStorage's fallback path
for platforms without os.pread/os.pwrite. The stream is not closed by
Raf; closing it remains the caller's responsibility.
"""
def __init__(self, stream):
self._stream = stream
self._lock = threading.Lock()
def read_at(self, buf: bytearray, offset: int) -> None:
"""Read exactly len(buf) bytes at offset into buf."""
with self._lock:
self._stream.seek(offset)
view = memoryview(buf)
done = 0
while done < len(view):
chunk = self._stream.read(len(view) - done)
if not chunk:
raise OSError(f"Short read: expected {len(view)}, got {done}")
view[done : done + len(chunk)] = chunk
done += len(chunk)
def write_at(self, data: bytes, offset: int) -> None:
"""Write exactly len(data) bytes at offset."""
with self._lock:
self._stream.seek(offset)
view = memoryview(data)
done = 0
while done < len(view):
done += self._stream.write(view[done:])
def get_size(self) -> int:
"""Return current stream size."""
with self._lock:
pos = self._stream.tell()
try:
return self._stream.seek(0, os.SEEK_END)
finally:
self._stream.seek(pos)
def set_size(self, size: int) -> None:
"""Resize the stream (truncate or extend with zeros)."""
self._stream.truncate(size)
def sync(self) -> None:
"""Flush the stream if it supports flushing."""
if hasattr(self._stream, "flush"):
self._stream.flush()
def __enter__(self) -> Self:
return self
def __exit__(self, *args) -> None:
pass
@runtime_checkable
class MerkleHasher(Protocol):
"""Protocol for Merkle tree hash functions."""
@property
def hash_len(self) -> int: ...
def hash_leaf(self, chunk: bytes, chunk_len: int, chunk_idx: int) -> bytes: ...
def hash_parent(
self, left: bytes, right: bytes, level: int, node_idx: int
) -> bytes: ...
def hash_empty(self, level: int, node_idx: int) -> bytes: ...
def hash_commitment(
self, structural_root: bytes, ctx: bytes, file_size: int
) -> bytes: ...
class SHA256MerkleHasher:
"""Default Merkle hasher using SHA-256 with domain-separated prefixes."""
hash_len = 32
def hash_leaf(self, chunk: bytes, chunk_len: int, chunk_idx: int) -> bytes:
h = hashlib.sha256()
h.update(b"\x00")
h.update(chunk_idx.to_bytes(8, "little"))
h.update(chunk[:chunk_len])
return h.digest()
def hash_parent(
self, left: bytes, right: bytes, level: int, node_idx: int
) -> bytes:
h = hashlib.sha256()
h.update(b"\x01")
h.update(level.to_bytes(4, "little"))
h.update(node_idx.to_bytes(8, "little"))
h.update(left)
h.update(right)
return h.digest()
def hash_empty(self, level: int, node_idx: int) -> bytes:
h = hashlib.sha256()
h.update(b"\x02")
h.update(level.to_bytes(4, "little"))
h.update(node_idx.to_bytes(8, "little"))
return h.digest()
def hash_commitment(
self, structural_root: bytes, ctx: bytes, file_size: int
) -> bytes:
h = hashlib.sha256()
h.update(b"\x03")
h.update(structural_root)
h.update(ctx)
h.update(file_size.to_bytes(8, "little"))
return h.digest()
# CFFI callbacks (module-level so they stay alive)
@ffi.callback("int(void*, uint8_t*, size_t, uint64_t)")
def _read_at_callback(user, buf, length, offset):
try:
storage = ffi.from_handle(user)
temp = bytearray(length)
storage.read_at(temp, offset)
ffi.buffer(buf, length)[:] = temp
return 0
except Exception:
ffi.errno = errno_module.EIO
return -1
@ffi.callback("int(void*, const uint8_t*, size_t, uint64_t)")
def _write_at_callback(user, buf, length, offset):
try:
storage = ffi.from_handle(user)
storage.write_at(bytes(ffi.buffer(buf, length)), offset)
return 0
except Exception:
ffi.errno = errno_module.EIO
return -1
@ffi.callback("int(void*, uint64_t*)")
def _get_size_callback(user, size_ptr):
try:
size_ptr[0] = ffi.from_handle(user).get_size()
return 0
except Exception:
ffi.errno = errno_module.EIO
return -1
@ffi.callback("int(void*, uint64_t)")
def _set_size_callback(user, size):
try:
ffi.from_handle(user).set_size(size)
return 0
except Exception:
ffi.errno = errno_module.EIO
return -1
@ffi.callback("int(void*)")
def _sync_callback(user):
try:
ffi.from_handle(user).sync()
return 0
except Exception:
ffi.errno = errno_module.EIO
return -1
@ffi.callback("int(void*, uint8_t*, size_t, const uint8_t*, size_t, uint64_t)")
def _hash_leaf_callback(user, out, out_len, chunk, chunk_len, chunk_idx):
try:
hasher = ffi.from_handle(user)
digest = hasher.hash_leaf(
bytes(ffi.buffer(chunk, chunk_len)), chunk_len, chunk_idx
)
if len(digest) != out_len:
ffi.errno = errno_module.EINVAL
return -1
ffi.memmove(out, digest, out_len)
return 0
except Exception:
ffi.errno = errno_module.EINVAL
return -1
@ffi.callback(
"int(void*, uint8_t*, size_t, const uint8_t*, const uint8_t*, uint32_t, uint64_t)"
)
def _hash_parent_callback(user, out, out_len, left, right, level, node_idx):
try:
hasher = ffi.from_handle(user)
digest = hasher.hash_parent(
bytes(ffi.buffer(left, out_len)),
bytes(ffi.buffer(right, out_len)),
level,
node_idx,
)
if len(digest) != out_len:
ffi.errno = errno_module.EINVAL
return -1
ffi.memmove(out, digest, out_len)
return 0
except Exception:
ffi.errno = errno_module.EINVAL
return -1
@ffi.callback("int(void*, uint8_t*, size_t, uint32_t, uint64_t)")
def _hash_empty_callback(user, out, out_len, level, node_idx):
try:
hasher = ffi.from_handle(user)
digest = hasher.hash_empty(level, node_idx)
if len(digest) != out_len:
ffi.errno = errno_module.EINVAL
return -1
ffi.memmove(out, digest, out_len)
return 0
except Exception:
ffi.errno = errno_module.EINVAL
return -1
@ffi.callback(
"int(void*, uint8_t*, size_t, const uint8_t*, const uint8_t*, size_t, uint64_t)"
)
def _hash_commitment_callback(
user, out, out_len, structural_root, ctx, ctx_len, file_size
):
try:
hasher = ffi.from_handle(user)
root_bytes = bytes(ffi.buffer(structural_root, out_len))
ctx_bytes = bytes(ffi.buffer(ctx, ctx_len)) if ctx != ffi.NULL else b""
digest = hasher.hash_commitment(root_bytes, ctx_bytes, file_size)
if len(digest) != out_len:
ffi.errno = errno_module.EINVAL
return -1
ffi.memmove(out, digest, out_len)
return 0
except Exception:
ffi.errno = errno_module.EINVAL
return -1
def _make_io(storage: Storage):
"""Build an aegis_raf_io* cdata for a Storage. Returns (io, handle)."""
handle = ffi.new_handle(storage)
io = ffi.new("aegis_raf_io*")
io.user = handle
io.read_at = _read_at_callback
io.write_at = _write_at_callback
io.get_size = _get_size_callback
io.set_size = _set_size_callback
io.sync = _sync_callback
return io, handle
def _allocate_scratch(cipher: Cipher, chunk_size: int):
"""Allocate an aligned aegis_raf_scratch*. Returns (raw, scratch)."""
size = cipher.raf_scratch_size(chunk_size)
raw = bytearray(size + SCRATCH_ALIGN)
raw_ptr = ffi.from_buffer(raw)
offset = (-int(ffi.cast("uintptr_t", raw_ptr))) & (SCRATCH_ALIGN - 1)
scratch = ffi.new("aegis_raf_scratch*")
scratch.buf = ffi.cast("uint8_t*", raw_ptr) + offset
scratch.len = size
return raw, scratch
def derive_master_key(master_key: Buffer, context: Buffer = b"") -> bytes:
"""Derive a context-bound RAF master key from an application master key.
Args:
master_key: A 16-byte or 32-byte application master key.
context: A public identifier for a file or file family
(at most 120 bytes for 16-byte keys, 72 for 32-byte keys).
Returns:
A RAF-scoped key of the same length as master_key.
Raises:
ValueError: If the key or context length is invalid.
RuntimeError: If the C KDF rejects validated inputs.
"""
master_key = memoryview(master_key)
context = memoryview(context)
key_size = master_key.nbytes
if key_size not in (16, 32):
raise ValueError(f"master_key must be 16 or 32 bytes, got {key_size}")
max_context = 120 if key_size == 16 else 72
if context.nbytes > max_context:
raise ValueError(
f"context must be at most {max_context} bytes for a {key_size}-byte key"
)
out = bytearray(key_size)
rc = lib.aegis_raf_derive_master_key(
ffi.from_buffer(out),
key_size,
bytes(master_key),
key_size,
bytes(context),
context.nbytes,
)
if rc != 0:
err_num = ffi.errno
err_name = errno_module.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"derive master key failed: {err_name}")
return bytes(out)
class RafInfo(NamedTuple):
"""Unverified file metadata from probe()."""
alg_id: int
chunk_size: int
file_size: int
def probe(storage: Storage) -> RafInfo:
"""Probe an encrypted file to determine its parameters.
WARNING: the returned values are read from the header WITHOUT
cryptographic verification. Only use them for scratch sizing and
algorithm selection; open() verifies the header MAC with the key.
Returns:
RafInfo(alg_id, chunk_size, file_size).
Raises:
RuntimeError: Invalid header or I/O failure.
"""
io, _handle = _make_io(storage)
info = ffi.new("aegis_raf_info*")
if lib.aegis_raf_probe(io, info) != 0:
err_num = ffi.errno
err_name = errno_module.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"probe failed: {err_name}")
return RafInfo(int(info.alg_id), int(info.chunk_size), int(info.file_size))
def _as_storage(storage_or_path, mode: str) -> tuple[Storage, bool]:
if isinstance(storage_or_path, (str, bytes, os.PathLike)):
return FileStorage(storage_or_path, mode), True
if isinstance(storage_or_path, io_module.BytesIO):
return StreamStorage(storage_or_path), False
return storage_or_path, False
def _resolve_cipher(cipher) -> Cipher:
if isinstance(cipher, str):
return _cipher_for_name(cipher)
return cipher
def create(
storage_or_path,
key: Buffer,
cipher,
*,
chunk_size: int = 65536,
truncate: bool = False,
merkle: bool | MerkleHasher = False,
merkle_max_chunks: int = 16384,
rng: _random.RandomSource | None = None,
) -> Raf:
"""Create a new encrypted file.
Args:
storage_or_path: A Storage instance or a path (opened "x+b", or "w+b"
when truncate=True).
key: Master key (cipher.KEYBYTES bytes).
cipher: A cipher module (from aeg.cipher()) or algorithm name.
chunk_size: Plaintext bytes per chunk (CHUNK_MIN..CHUNK_MAX, multiple of 16).
truncate: Overwrite an existing file.
merkle: Enable Merkle tree. True uses SHA256MerkleHasher, or pass a
custom MerkleHasher instance.
merkle_max_chunks: Maximum number of chunks the Merkle tree can track.
rng: Random source (see aeg.random). Default: a fresh aeg.random.Random
seeded for the same cipher module passed as cipher=; any callable(n)
returning bytes overrides.
Raises:
TypeError: If the key length is invalid.
ValueError: If chunk_size or merkle parameters are invalid.
FileExistsError: If the file exists and truncate is False.
RuntimeError: If creation fails.
"""
cipher = _resolve_cipher(cipher)
if not (CHUNK_MIN <= chunk_size <= CHUNK_MAX):
raise ValueError(
f"chunk_size must be {CHUNK_MIN}-{CHUNK_MAX}, got {chunk_size}"
)
if chunk_size % 16 != 0:
raise ValueError(f"chunk_size must be a multiple of 16, got {chunk_size}")
storage, owns = _as_storage(storage_or_path, "w+b" if truncate else "x+b")
return Raf(
storage,
key,
cipher,
owns_storage=owns,
creating=True,
chunk_size=chunk_size,
truncate=truncate,
merkle=merkle,
merkle_max_chunks=merkle_max_chunks,
rng=rng,
)
def open(
storage_or_path,
key: Buffer,
cipher,
*,
merkle: bool | MerkleHasher = False,
merkle_max_chunks: int = 16384,
rng: _random.RandomSource | None = None,
) -> Raf:
"""Open an existing encrypted file.
The header is probed to size internal buffers and its alg_id is checked
against cipher.RAF_ALG_ID; a mismatch raises ValueError (the algorithm is
never switched silently).
Args:
storage_or_path: A Storage instance or a path (opened "r+b").
key: Master key (cipher.KEYBYTES bytes).
cipher: A cipher module (from aeg.cipher()) or algorithm name.
merkle: Enable Merkle tree (call merkle_rebuild() before use).
merkle_max_chunks: Maximum number of chunks the Merkle tree can track.
rng: Random source (see aeg.random). Default: a fresh aeg.random.Random
seeded for the same cipher module passed as cipher=; any callable(n)
returning bytes overrides.
Raises:
TypeError: If the key length is invalid.
ValueError: If the algorithm doesn't match cipher or authentication fails.
FileNotFoundError: If the file does not exist.
RuntimeError: If opening fails.
"""
cipher = _resolve_cipher(cipher)
storage, owns = _as_storage(storage_or_path, "r+b")
info = probe(storage)
if info.alg_id != cipher.RAF_ALG_ID:
raise ValueError(
f"file uses algorithm id {info.alg_id}, "
f"expected {cipher.RAF_ALG_ID} ({cipher.NAME})"
)
return Raf(
storage,
key,
cipher,
owns_storage=owns,
creating=False,
chunk_size=info.chunk_size,
truncate=False,
merkle=merkle,
merkle_max_chunks=merkle_max_chunks,
rng=rng,
)
class Raf:
"""File-like random-access encrypted file. Use raf.create() / raf.open()."""
def __init__(
self,
storage: Storage,
key: Buffer,
cipher: Cipher,
*,
owns_storage: bool,
creating: bool,
chunk_size: int,
truncate: bool,
merkle: bool | MerkleHasher,
merkle_max_chunks: int,
rng,
):
self._cipher = cipher
self._storage = storage
self._owns_storage = owns_storage
self._closed = False
self._position = 0
self._ctx = cipher.new_raf_ctx()
self._io, self._storage_handle = _make_io(storage)
if rng is None:
rng = _random.Random(cipher)
self._rng, self._rng_keepalive = _random.as_raf_rng(rng)
self._raw_scratch, self._scratch = _allocate_scratch(cipher, chunk_size)
self._config = ffi.new("aegis_raf_config*")
self._config.scratch = self._scratch
self._config.chunk_size = chunk_size
flags = 0
if creating:
flags |= _FLAG_CREATE
if truncate:
flags |= _FLAG_TRUNCATE
self._config.flags = flags
self._config.merkle = ffi.NULL
self._merkle_hasher = None
self._merkle_cfg = None
self._merkle_buf = None
self._merkle_hasher_handle = None
if merkle:
hasher = SHA256MerkleHasher() if merkle is True else merkle
if merkle_max_chunks <= 0:
raise ValueError("merkle_max_chunks must be > 0")
if not (_MERKLE_HASH_MIN <= hasher.hash_len <= _MERKLE_HASH_MAX):
raise ValueError(
f"hash_len must be between {_MERKLE_HASH_MIN} and "
f"{_MERKLE_HASH_MAX}, got {hasher.hash_len}"
)
mcfg = ffi.new("aegis_raf_merkle_config*")
mcfg.hash_len = hasher.hash_len
mcfg.max_chunks = merkle_max_chunks
buf_size = lib.aegis_raf_merkle_buffer_size(mcfg)
if buf_size == 0 or buf_size >= int(ffi.cast("size_t", -1)):
raise ValueError("Merkle tree buffer size overflow")
self._merkle_buf = bytearray(buf_size)
self._merkle_hasher_handle = ffi.new_handle(hasher)
mcfg.hash_leaf = _hash_leaf_callback
mcfg.hash_parent = _hash_parent_callback
mcfg.hash_empty = _hash_empty_callback
mcfg.hash_commitment = _hash_commitment_callback
mcfg.user = self._merkle_hasher_handle
mcfg.buf = ffi.from_buffer(self._merkle_buf)
mcfg.len = buf_size
self._merkle_hasher = hasher
self._merkle_cfg = mcfg
self._config.merkle = mcfg
if creating:
cipher.raf_create(key, self._ctx, self._io, self._rng, self._config)
else:
cipher.raf_open(key, self._ctx, self._io, self._rng, self._config)
def _check_open(self) -> None:
if self._closed:
raise ValueError("I/O operation on closed file")
def read(self, size: int = -1, offset: int | None = None) -> bytes:
"""Read and decrypt bytes, advancing the current position.
Args:
size: Number of bytes to read (-1 for all remaining).
offset: Position to read from (None uses current position).
Returns:
Decrypted bytes (fewer than requested at EOF).
Raises:
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
self._check_open()
if offset is None:
offset = self._position
elif offset < 0:
raise ValueError(f"offset must be non-negative, got {offset}")
if size < 0:
size = max(0, self.size - offset)
if size == 0:
return b""
out = self._cipher.raf_read(self._ctx, size, offset)
self._position = offset + len(out)
return bytes(out)
def pread(self, size: int, offset: int) -> bytes:
"""Read at offset without updating the position (like os.pread)."""
self._check_open()
if offset < 0:
raise ValueError(f"offset must be non-negative, got {offset}")
if size < 0:
raise ValueError(f"size must be non-negative, got {size}")
if size == 0:
return b""
return bytes(self._cipher.raf_read(self._ctx, size, offset))
def read_into(self, buf: Buffer, offset: int | None = None) -> int:
"""Read and decrypt into buf, advancing the position.
Returns:
Number of bytes actually read.
"""
self._check_open()
buf = memoryview(buf)
if offset is None:
offset = self._position
elif offset < 0:
raise ValueError(f"offset must be non-negative, got {offset}")
if buf.nbytes == 0:
return 0
out = self._cipher.raf_read(self._ctx, buf.nbytes, offset, into=buf)
n = len(out)
self._position = offset + n
return n
def write(self, data: Buffer, offset: int | None = None) -> int:
"""Encrypt and write bytes, advancing the current position.
Returns:
Number of bytes written (always len(data) on success).
Raises:
ValueError: If the write exceeds merkle_max_chunks.
RuntimeError: If the write fails.
"""
self._check_open()
data = memoryview(data)
if offset is None:
offset = self._position
elif offset < 0:
raise ValueError(f"offset must be non-negative, got {offset}")
if data.nbytes == 0:
return 0
try:
n = self._cipher.raf_write(self._ctx, data, offset)
except RuntimeError as e:
if "EOVERFLOW" in str(e) and self._merkle_cfg is not None:
new_end = offset + data.nbytes
if new_end <= 0xFFFFFFFFFFFFFFFF:
chunk_size = self._config.chunk_size
new_chunks = (new_end + chunk_size - 1) // chunk_size
if new_chunks > self._merkle_cfg.max_chunks:
raise ValueError(
f"write exceeds merkle_max_chunks "
f"({self._merkle_cfg.max_chunks}); "
"open the file with a larger merkle_max_chunks"
) from e
raise
self._position = offset + n
return n
def pwrite(self, data: Buffer, offset: int) -> int:
"""Write at offset without updating the position (like os.pwrite)."""
pos = self._position
try:
return self.write(data, offset)
finally:
self._position = pos
def truncate(self, size: int | None = None) -> int:
"""Resize the file (default: current position). Returns the new size."""
self._check_open()
if size is None:
size = self._position
self._cipher.raf_truncate(self._ctx, size)
return size
def seek(self, offset: int, whence: int = 0) -> int:
"""Move the file position (whence: 0=absolute, 1=relative, 2=from end)."""
self._check_open()
if whence == 0:
new_pos = offset
elif whence == 1:
new_pos = self._position + offset
elif whence == 2:
new_pos = self.size + offset
else:
raise ValueError(f"invalid whence: {whence}")
if new_pos < 0:
raise ValueError(f"negative seek position: {new_pos}")
self._position = new_pos
return new_pos
def tell(self) -> int:
"""Return the current position."""
return self._position
@property
def size(self) -> int:
"""Logical plaintext file size."""
self._check_open()
return self._cipher.raf_size(self._ctx)
def sync(self) -> None:
"""Flush writes to backing storage."""
self._check_open()
self._cipher.raf_sync(self._ctx)
def close(self) -> None:
"""Close the file, syncing and zeroizing key material."""
if not self._closed:
self._closed = True
self._cipher.raf_close(self._ctx)
if self._owns_storage and hasattr(self._storage, "close"):
self._storage.close()
@property
def closed(self) -> bool:
"""True if the file has been closed."""
return self._closed
def __enter__(self) -> Self:
return self
def __exit__(self, *args) -> None:
self.close()
def _check_merkle_enabled(self) -> None:
if self._merkle_cfg is None:
raise ValueError("Merkle tree is not enabled on this file")
def merkle_rebuild(self) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Must be called after opening an existing file with merkle enabled.
"""
self._check_open()
self._check_merkle_enabled()
self._cipher.raf_merkle_rebuild(self._ctx)
def merkle_verify(self) -> int | None:
"""Verify every chunk's hash against the Merkle tree.
Returns:
None if all chunks match, otherwise the index of the first
corrupted chunk.
"""
self._check_open()
self._check_merkle_enabled()
return self._cipher.raf_merkle_verify(self._ctx)
@property
def root_hash(self) -> bytes | None:
"""Current Merkle root commitment hash, or None if Merkle is not enabled."""
self._check_open()
if self._merkle_cfg is None:
return None
return self._cipher.raf_merkle_commitment(
self._ctx, self._merkle_hasher.hash_len
)
def verify_root(self, expected: Buffer) -> None:
"""Rebuild the Merkle tree and verify the root matches expected.
Raises:
ValueError: If the root mismatches or expected has the wrong length.
"""
self._check_open()
self._check_merkle_enabled()
expected = memoryview(expected)
if expected.nbytes != self._merkle_hasher.hash_len:
raise ValueError(
f"expected must be {self._merkle_hasher.hash_len} bytes, "
f"got {expected.nbytes}"
)
self.merkle_rebuild()
if self.root_hash != bytes(expected):
raise ValueError("authentication failed: merkle root mismatch")
+114
View File
@@ -0,0 +1,114 @@
"""Pluggable random sources for aeg, built on AEGIS streams.
``Random`` is seeded once from the OS (secrets), then deterministic: each call
produces AEGIS keystream under the current key/nonce and increments the nonce
afterwards (little-endian), so no output block is ever produced twice.
Modeled on quantumpipe's ChaCha20-based Random class.
Note: within the ``aeg`` package namespace this submodule shadows the stdlib
``random`` module; import it as ``from aeg import random`` (or ``aeg.random``).
"""
import errno
import secrets
from typing import Protocol
from ._loader import ffi
from .util import Buffer, nonce_increment
__all__ = ["RandomSource", "Random", "system_random", "as_raf_rng"]
class RandomSource(Protocol):
"""Callable returning n cryptographically secure random bytes."""
def __call__(self, n: int) -> bytes: ...
class Random:
"""AEGIS stream RNG: seeded once from the OS, then deterministic.
Each call produces keystream under the current key/nonce and increments
the nonce afterwards, so no output block is ever produced twice.
The primary way to get one is the cipher module's own random() helper,
e.g. ``aeg.cipher("AEGIS-128X2").random()``, which returns a per-thread
singleton (each thread gets its own OS-seeded generator; no locking).
"""
def __init__(self, cipher, *, seed: Buffer | None = None):
"""Create a Random using the given cipher module.
Args:
cipher: Cipher module (from aeg.cipher()).
seed: Optional nonce || key bytes (NONCEBYTES + KEYBYTES long).
Primarily for tests; default seeds from the OS via secrets.
Raises:
TypeError: If seed length is invalid.
"""
self._cipher = cipher
if seed is None:
seed = secrets.token_bytes(cipher.NONCEBYTES + cipher.KEYBYTES)
seed = memoryview(seed)
if seed.nbytes != cipher.NONCEBYTES + cipher.KEYBYTES:
raise TypeError(
f"seed length must be {cipher.NONCEBYTES + cipher.KEYBYTES} "
f"(nonce || key) for {cipher.NAME}"
)
self._nonce = bytearray(seed[: cipher.NONCEBYTES])
self._key = seed[cipher.NONCEBYTES :]
def bytes(self, n: int) -> bytearray:
"""Return n random bytes as a bytearray."""
out = self._cipher.stream(self._key, self._nonce, n)
nonce_increment(self._nonce)
return out
def into(self, buf: Buffer) -> None:
"""Fill buf in place with random bytes."""
mv = memoryview(buf).cast("B")
self._cipher.stream(self._key, self._nonce, into=mv)
nonce_increment(self._nonce)
def __call__(self, n: int) -> bytearray:
"""Alias for bytes(n); satisfies the RandomSource protocol."""
return self.bytes(n)
def system_random(n: int) -> bytes:
"""Random source backed directly by secrets.token_bytes."""
return secrets.token_bytes(n)
@ffi.callback("int(void*, uint8_t*, size_t)")
def _random_callback(user, out, length):
try:
source = ffi.from_handle(user)
data = source(length)
if len(data) != length:
ffi.errno = errno.EINVAL
return -1
ffi.memmove(out, data, length)
return 0
except Exception:
ffi.errno = errno.EIO
return -1
def as_raf_rng(source: RandomSource):
"""Build an aegis_raf_rng* cdata wrapping a Python random source.
Args:
source: Callable returning n random bytes, e.g. a Random instance
or system_random.
Returns:
Tuple of (rng, keepalive). Keep ``keepalive`` alive for as long as
the rng may be called by C code.
"""
handle = ffi.new_handle(source)
rng = ffi.new("aegis_raf_rng*")
rng.user = handle
rng.random = _random_callback
return rng, (handle, _random_callback)
+116
View File
@@ -0,0 +1,116 @@
"""SecureBuffer: locked, core-dump-excluded memory for sensitive data.
Backed by an anonymous mmap that is locked against swapping (mlock /
VirtualLock) and excluded from core dumps (MADV_DONTDUMP) where available.
The contents are wiped on close().
SecureBuffer is not used for return values; allocate one yourself and pass
it as an ``into`` target to keep plaintext/keys out of the regular heap:
with SecureBuffer(4096) as buf:
ciph.decrypt(key, nonce, ct, mac, into=buf)
Adapted from quantumpipe's securebuf module.
"""
import ctypes
import mmap
import os
from contextlib import suppress
from typing import Self
from .util import wipe
__all__ = ["SecureBuffer"]
class SecureBuffer:
"""A fixed-size buffer in locked, non-dumped memory, wiped on close."""
def __init__(self, size: int):
if size <= 0:
raise ValueError(f"size must be positive, got {size}")
self._mmap = mmap.mmap(-1, size, access=mmap.ACCESS_WRITE)
try:
self._lock()
self._dontdump()
except BaseException:
self._mmap.close()
raise
@property
def closed(self) -> bool:
"""True if the buffer has been closed."""
return self._mmap.closed
def _addr(self) -> int:
return ctypes.addressof(ctypes.c_char.from_buffer(self._mmap))
def _lock(self) -> None:
addr, size = self._addr(), len(self._mmap)
if os.name == "nt":
api = ctypes.WinDLL("kernel32", use_last_error=True)
api.VirtualLock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
api.VirtualLock.restype = ctypes.c_bool
if not api.VirtualLock(addr, size):
raise ctypes.WinError(ctypes.get_last_error())
else:
libc = ctypes.CDLL(None, use_errno=True)
libc.mlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
libc.mlock.restype = ctypes.c_int
if libc.mlock(addr, size):
err = ctypes.get_errno()
raise OSError(err, os.strerror(err))
def _unlock(self) -> None:
addr, size = self._addr(), len(self._mmap)
if os.name == "nt":
api = ctypes.WinDLL("kernel32", use_last_error=True)
api.VirtualUnlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
api.VirtualUnlock.restype = ctypes.c_bool
if not api.VirtualUnlock(addr, size):
raise ctypes.WinError(ctypes.get_last_error())
else:
libc = ctypes.CDLL(None, use_errno=True)
libc.munlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t)
libc.munlock.restype = ctypes.c_int
if libc.munlock(addr, size):
err = ctypes.get_errno()
raise OSError(err, os.strerror(err))
def _dontdump(self) -> None:
if hasattr(self._mmap, "madvise") and hasattr(mmap, "MADV_DONTDUMP"):
self._mmap.madvise(mmap.MADV_DONTDUMP)
def close(self) -> None:
"""Wipe the contents, unlock and release the memory. Idempotent."""
if self.closed:
return
wipe(self._mmap)
self._unlock()
self._mmap.close()
def __buffer__(self, flags: int) -> memoryview:
return memoryview(self._mmap)
def __getitem__(self, key):
return memoryview(self._mmap)[key]
def __setitem__(self, key, value):
memoryview(self._mmap)[key] = value
def __bytes__(self):
raise TypeError("SecureBuffer cannot be converted to bytes")
def __len__(self) -> int:
return len(self._mmap)
def __enter__(self) -> Self:
return self
def __exit__(self, *args) -> None:
self.close()
def __del__(self):
with suppress(Exception):
self.close()
+75
View File
@@ -0,0 +1,75 @@
"""Utility helpers for aeg.
Currently provides Python-side aligned allocation helpers that avoid relying
on libc/posix_memalign. Memory is owned by Python; C code only borrows it.
"""
from collections.abc import Buffer
from ._loader import ffi
__all__ = ["new_aligned_struct", "aligned_address", "Buffer", "nonce_increment", "wipe"]
def aligned_address(obj) -> int:
"""Return the integer address of the start of a cffi array object."""
return int(ffi.cast("uintptr_t", ffi.addressof(obj, 0)))
class StructHolder:
"""Proxy object for aligned struct allocation.
Exposes the aligned pointer as a property and wipes the buffer on deletion.
"""
def __init__(self, ptr: object, view: memoryview):
self._ptr = ptr
self._view = view # Keep memoryview slice and its bytearray alive
@property
def ptr(self) -> object:
"""The aligned pointer to the struct."""
return self._ptr
def __del__(self):
wipe(self._view)
del self._ptr, self._view
def new_aligned_struct(ctype: str, alignment: int) -> StructHolder:
"""Allocate memory for one instance of ``ctype`` with requested alignment."""
# Allocate backing storage with extra space for alignment
size = ffi.sizeof(ctype)
view = memoryview(bytearray(size + alignment - 1))
# Compute alignment offset from the base address
offset = (-aligned_address(ffi.from_buffer(view))) & (alignment - 1)
# Slice the memoryview to the aligned region (keeps bytearray alive)
view = view[offset : offset + size]
return StructHolder(ffi.from_buffer(f"{ctype} *", view), view)
def nonce_increment(nonce: Buffer) -> None:
"""Increment the nonce in place using little-endian byte order.
Useful for generating unique nonces for each consecutive message.
Args:
nonce: The nonce buffer to increment (modified in place).
"""
n = memoryview(nonce)
for i in range(len(n)):
if n[i] < 255:
n[i] += 1
return
n[i] = 0
def wipe(buffer: Buffer) -> None:
"""Securely clearing sensitive data from memory. Sets all bytes of the buffer to 0xFF.
Args:
buffer: The buffer to wipe (modified in place).
"""
# This is the fastest method I have found in Python
n = memoryview(buffer).cast("B")
n[:] = b"\xff" * len(n)
-89
View File
@@ -1,89 +0,0 @@
#! /usr/bin/env python3
import json
import re
def tvdump(topic, tvs):
with open(filename(topic), "w") as f:
f.write(json.dumps(tvs, indent=2))
print(json.dumps(tvs, indent=2))
def filename(topic):
return re.sub(r"[^a-z0-9]+", "-", topic.lower()) + ".json"
header = True
in_tv = False
tv = {}
tvs = []
must_fail = False
with open("../draft-irtf-cfrg-aegis-aead.md") as f:
for line in f:
line = line.strip()
if line == "":
continue
if line.startswith("# Test Vectors"):
header = False
continue
if header:
continue
if line.startswith("## "):
if len(tvs) > 0:
tvdump(topic, tvs)
topic = line[3:]
tv_name = topic
tvs = []
continue
if line.startswith("### "):
tv_name = line[4:]
tv = {"test": tv_name}
in_tv = False
continue
if line == "~~~ test-vectors":
in_tv = True
tv = {"name": tv_name}
if must_fail:
tv["error"] = "verification failed"
must_fail = False
continue
if line == "~~~":
tvs.append(tv)
in_tv = False
current_key = None
continue
if line.find("verification failed") != -1:
must_fail = True
continue
if line == "After initialization:":
tv_name = tv_name + " (after initialization)"
if not in_tv:
continue
parts = line.split(":")
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip()
if key == "After Update":
continue
if key in tv:
key = key + "_2"
tv[key] = value
current_key = key
continue
if not current_key:
continue
tv[key] += line.strip()
tvdump(topic, tvs)
+9 -13
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4 from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from .util import random_split_bytes from .util import random_split_bytes
@@ -147,14 +147,12 @@ def test_encrypt_decrypt_incremental(vector):
expected_tag128 = bytes.fromhex(vector["tag128"]) expected_tag128 = bytes.fromhex(vector["tag128"])
# Incremental encryption with random chunking # Incremental encryption with random chunking
encryptor = alg.Encryptor(key, nonce, ad) encryptor = alg.Encryptor(key, nonce, ad, maclen=16)
ct_chunks = [] ct_chunks = []
for chunk in random_split_bytes(msg): for chunk in random_split_bytes(msg):
ct_result = encryptor.update(chunk) ct_result = encryptor.update(chunk)
ct_chunks.append(bytes(ct_result)) ct_chunks.append(bytes(ct_result))
final_output = encryptor.final(maclen=16) computed_mac = bytes(encryptor.final())
ct_chunks.append(bytes(final_output[:-16])) # ciphertext part
computed_mac = bytes(final_output[-16:]) # MAC part
# Combine ciphertext chunks # Combine ciphertext chunks
computed_ct = b"".join(ct_chunks) computed_ct = b"".join(ct_chunks)
@@ -170,7 +168,7 @@ def test_encrypt_decrypt_incremental(vector):
) )
# Incremental decryption with different random chunking # Incremental decryption with different random chunking
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
pt_chunks = [] pt_chunks = []
for chunk in random_split_bytes(computed_ct): for chunk in random_split_bytes(computed_ct):
pt_chunks.append(bytes(decryptor.update(chunk))) pt_chunks.append(bytes(decryptor.update(chunk)))
@@ -187,14 +185,12 @@ def test_encrypt_decrypt_incremental(vector):
expected_tag256 = bytes.fromhex(vector["tag256"]) expected_tag256 = bytes.fromhex(vector["tag256"])
# Incremental encryption with random chunking # Incremental encryption with random chunking
encryptor = alg.Encryptor(key, nonce, ad) encryptor = alg.Encryptor(key, nonce, ad, maclen=32)
ct_chunks = [] ct_chunks = []
for chunk in random_split_bytes(msg): for chunk in random_split_bytes(msg):
ct_result = encryptor.update(chunk) ct_result = encryptor.update(chunk)
ct_chunks.append(bytes(ct_result)) ct_chunks.append(bytes(ct_result))
final_output = encryptor.final(maclen=32) computed_mac = bytes(encryptor.final())
ct_chunks.append(bytes(final_output[:-32])) # ciphertext part
computed_mac = bytes(final_output[-32:]) # MAC part
# Combine ciphertext chunks # Combine ciphertext chunks
computed_ct = b"".join(ct_chunks) computed_ct = b"".join(ct_chunks)
@@ -210,7 +206,7 @@ def test_encrypt_decrypt_incremental(vector):
) )
# Incremental decryption with different random chunking # Incremental decryption with different random chunking
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
pt_chunks = [] pt_chunks = []
for chunk in random_split_bytes(computed_ct): for chunk in random_split_bytes(computed_ct):
pt_chunks.append(bytes(decryptor.update(chunk))) pt_chunks.append(bytes(decryptor.update(chunk)))
@@ -229,14 +225,14 @@ def test_encrypt_decrypt_incremental(vector):
# Test that incremental decryption fails with the provided (invalid) MACs # Test that incremental decryption fails with the provided (invalid) MACs
if "tag128" in vector: if "tag128" in vector:
invalid_mac = bytes.fromhex(vector["tag128"]) invalid_mac = bytes.fromhex(vector["tag128"])
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=16)
decryptor.update(ct) # This should succeed decryptor.update(ct) # This should succeed
with pytest.raises(ValueError, match="authentication failed"): with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(invalid_mac) decryptor.final(invalid_mac)
if "tag256" in vector: if "tag256" in vector:
invalid_mac = bytes.fromhex(vector["tag256"]) invalid_mac = bytes.fromhex(vector["tag256"])
decryptor = alg.Decryptor(key, nonce, ad) decryptor = alg.Decryptor(key, nonce, ad, maclen=32)
decryptor.update(ct) # This should succeed decryptor.update(ct) # This should succeed
with pytest.raises(ValueError, match="authentication failed"): with pytest.raises(ValueError, match="authentication failed"):
decryptor.final(invalid_mac) decryptor.final(invalid_mac)
File diff suppressed because it is too large. Load diff
+173 -5
View File
@@ -2,10 +2,14 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from pyaegis import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from .util import random_split_bytes from .util import random_split_bytes
# All AEGIS algorithm modules
ALL_ALGORITHMS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
def load_mac_test_vectors(): def load_mac_test_vectors():
"""Load MAC test vectors from JSON file.""" """Load MAC test vectors from JSON file."""
@@ -81,10 +85,10 @@ def test_mac_class(vector):
# Test 128-bit MAC if present # Test 128-bit MAC if present
if "tag128" in vector: if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"]) expected_tag128 = bytes.fromhex(vector["tag128"])
mac_state = alg.Mac(key, nonce) mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data): for chunk in random_split_bytes(data):
mac_state.update(chunk) mac_state.update(chunk)
computed_tag128 = mac_state.final(maclen=16) computed_tag128 = mac_state.final()
assert computed_tag128 == expected_tag128, ( assert computed_tag128 == expected_tag128, (
f"128-bit MAC mismatch for {vector['name']}" f"128-bit MAC mismatch for {vector['name']}"
) )
@@ -92,10 +96,174 @@ def test_mac_class(vector):
# Test 256-bit MAC if present # Test 256-bit MAC if present
if "tag256" in vector: if "tag256" in vector:
expected_tag256 = bytes.fromhex(vector["tag256"]) expected_tag256 = bytes.fromhex(vector["tag256"])
mac_state = alg.Mac(key, nonce) mac_state = alg.Mac(key, nonce, maclen=32)
for chunk in random_split_bytes(data): for chunk in random_split_bytes(data):
mac_state.update(chunk) mac_state.update(chunk)
computed_tag256 = mac_state.final(maclen=32) computed_tag256 = mac_state.final()
assert computed_tag256 == expected_tag256, ( assert computed_tag256 == expected_tag256, (
f"256-bit MAC mismatch for {vector['name']}" f"256-bit MAC mismatch for {vector['name']}"
) )
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
def test_mac_class_with_digest(vector):
"""Test MAC computation using digest() and hexdigest() instead of final()."""
alg = get_algorithm_module(vector["name"])
key = bytes.fromhex(vector["key"])
nonce = bytes.fromhex(vector["nonce"])
data = bytes.fromhex(vector["data"])
# Test 128-bit MAC if present
if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"])
# Test with digest()
mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
computed_tag128 = mac_state.digest()
assert computed_tag128 == expected_tag128, (
f"128-bit MAC mismatch for {vector['name']} using digest()"
)
# Test that digest() can be called multiple times
computed_tag128_again = mac_state.digest()
assert computed_tag128 == computed_tag128_again, (
"digest() should return the same value on repeated calls"
)
# Test hexdigest()
mac_state2 = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state2.update(chunk)
hex_tag = mac_state2.hexdigest()
assert hex_tag == expected_tag128.hex(), (
f"128-bit MAC hexdigest mismatch for {vector['name']}"
)
# Test that hexdigest() can be called multiple times
hex_tag_again = mac_state2.hexdigest()
assert hex_tag == hex_tag_again, (
"hexdigest() should return the same value on repeated calls"
)
# Test 256-bit MAC if present
if "tag256" in vector:
expected_tag256 = bytes.fromhex(vector["tag256"])
# Test with digest()
mac_state = alg.Mac(key, nonce, maclen=32)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
computed_tag256 = mac_state.digest()
assert computed_tag256 == expected_tag256, (
f"256-bit MAC mismatch for {vector['name']} using digest()"
)
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
def test_mac_clone(vector):
"""Test that cloning a Mac state works correctly."""
alg = get_algorithm_module(vector["name"])
key = bytes.fromhex(vector["key"])
nonce = bytes.fromhex(vector["nonce"])
data = bytes.fromhex(vector["data"])
# Test 128-bit MAC if present
if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"])
mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
# Clone the state
cloned_state = mac_state.clone()
# Both should produce the same tag
tag1 = mac_state.final()
tag2 = cloned_state.final()
assert tag1 == expected_tag128
assert tag2 == expected_tag128
assert tag1 == tag2
@pytest.mark.parametrize("vector", load_mac_test_vectors(), ids=get_test_id)
def test_mac_reset(vector):
"""Test that resetting a Mac state works correctly."""
alg = get_algorithm_module(vector["name"])
key = bytes.fromhex(vector["key"])
nonce = bytes.fromhex(vector["nonce"])
data = bytes.fromhex(vector["data"])
# Test 128-bit MAC if present
if "tag128" in vector:
expected_tag128 = bytes.fromhex(vector["tag128"])
mac_state = alg.Mac(key, nonce, maclen=16)
for chunk in random_split_bytes(data):
mac_state.update(chunk)
tag1 = mac_state.final()
assert tag1 == expected_tag128
# Reset and compute again
mac_state.reset()
for chunk in random_split_bytes(data):
mac_state.update(chunk)
tag2 = mac_state.final()
assert tag2 == expected_tag128
assert tag1 == tag2
@pytest.mark.parametrize("alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1])
def test_mac_reset_after_digest(alg):
"""Test that reset() clears the cached digest and allows reuse."""
key = alg.random_key()
nonce = alg.random_nonce()
mac_state = alg.Mac(key, nonce)
mac_state.update(b"Hello, world!")
tag1 = mac_state.digest()
# After digest(), update should fail
with pytest.raises(RuntimeError):
mac_state.update(b"More data")
# Reset should clear the cached digest
mac_state.reset()
# Now we should be able to update again
mac_state.update(b"Different data")
tag2 = mac_state.digest()
# Tags should be different since we used different data
assert tag1 != tag2
@pytest.mark.parametrize("alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1])
def test_mac_clone_preserves_cached_digest(alg):
"""Test that cloning preserves the cached digest state."""
key = alg.random_key()
nonce = alg.random_nonce()
mac_state = alg.Mac(key, nonce)
mac_state.update(b"Hello, world!")
tag1 = mac_state.digest()
# Clone after digest
cloned_state = mac_state.clone()
# Both should return the same cached tag
tag2 = cloned_state.digest()
assert tag1 == tag2
# Both should be unable to update
with pytest.raises(RuntimeError):
mac_state.update(b"More data")
with pytest.raises(RuntimeError):
cloned_state.update(b"More data")
+377
View File
@@ -0,0 +1,377 @@
import pytest
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4, raf
from aeg import random as aeg_random
CIPHERS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
CHUNK = 1024
def cipher_id(c):
return c.NAME
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_roundtrip_bytesio(c):
key = c.random_key()
data = bytes(range(256)) * 20 # spans multiple chunks
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
assert f.write(data) == len(data)
assert f.size == len(data)
with raf.open(st, key, c) as f:
assert f.read() == data
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_roundtrip_io_bytesio(c):
"""A bare io.BytesIO is auto-wrapped in StreamStorage."""
import io
key = c.random_key()
data = bytes(range(256)) * 20
bio = io.BytesIO()
with raf.create(bio, key, c, chunk_size=CHUNK) as f:
f.write(data)
f.pwrite(b"XY", 1000) # unaligned write across a chunk boundary
data = data[:1000] + b"XY" + data[1002:]
f.truncate(2000)
with raf.open(bio, key, c) as f:
assert f.size == 2000
assert f.pread(6, 999) == data[999:1005]
assert f.read() == data[:2000]
bio.close()
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_roundtrip_filestorage(c, tmp_path):
key = c.random_key()
data = b"file-backed" * 500
path = tmp_path / "test.raf"
with raf.create(path, key, c, chunk_size=CHUNK) as f:
f.write(data)
with raf.open(path, key, c) as f:
assert f.read() == data
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_random_access(c):
key = c.random_key()
st = raf.BytesIOStorage()
data = bytearray(4097)
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(data)
# unaligned writes across chunk boundaries
f.pwrite(b"ABCDEF", 1000) # crosses chunk 0/1 boundary at 1024
f.pwrite(b"z", 4096) # last byte
data[1000:1006] = b"ABCDEF"
data[4096:4097] = b"z"
assert f.pread(6, 1000) == b"ABCDEF"
assert f.pread(4097, 0) == bytes(data)
assert f.tell() == len(data) # pwrite/pread don't move the position
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_seek_tell_read_into(c):
key = c.random_key()
st = raf.BytesIOStorage()
data = b"0123456789" * 100
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(data)
assert f.tell() == len(data)
assert f.seek(10) == 10
assert f.read(5) == data[10:15]
assert f.tell() == 15
assert f.seek(-5, 1) == 10
assert f.seek(0, 2) == len(data)
buf = bytearray(20)
assert f.read_into(buf, 100) == 20
assert bytes(buf) == data[100:120]
with pytest.raises(ValueError):
f.seek(-1)
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_truncate(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(b"x" * 3000)
assert f.truncate(1500) == 1500
assert f.size == 1500
assert f.pread(1500, 0) == b"x" * 1500
f.truncate(2500) # grow, zero-filled
assert f.size == 2500
assert f.pread(1000, 1500) == b"\x00" * 1000
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_wrong_key(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(b"secret")
with pytest.raises(ValueError, match="authentication failed"):
raf.open(st, c.random_key(), c)
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_tampered_chunk(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(b"y" * 2048)
# flip a ciphertext byte inside the first chunk record (after the nonce)
st._data[raf.HEADER_SIZE + c.NONCEBYTES + 10] ^= 1
with raf.open(st, key, c) as f:
with pytest.raises(ValueError, match="authentication failed"):
f.read()
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_probe_and_cipher_mismatch(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(b"data")
info = raf.probe(st)
assert info.alg_id == c.RAF_ALG_ID
assert info.chunk_size == CHUNK
assert info.file_size == 4
other = aegis256 if c is not aegis256 else aegis128l
with pytest.raises(ValueError, match="algorithm id"):
raf.open(st, key, other)
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_create_existing(c, tmp_path):
key = c.random_key()
path = tmp_path / "exists.raf"
raf.create(path, key, c, chunk_size=CHUNK).close()
with pytest.raises(FileExistsError):
raf.create(path, key, c, chunk_size=CHUNK)
with raf.create(path, key, c, chunk_size=CHUNK, truncate=True) as f:
assert f.size == 0
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_open_missing(c, tmp_path):
with pytest.raises(FileNotFoundError):
raf.open(tmp_path / "missing.raf", c.random_key(), c)
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_merkle(c):
key = c.random_key()
st = raf.BytesIOStorage()
data = b"m" * 5000
with raf.create(st, key, c, chunk_size=CHUNK, merkle=True) as f:
f.write(data)
root1 = f.root_hash
assert root1 is not None and len(root1) == 32
assert f.merkle_verify() is None
f.pwrite(b"X", 0)
root2 = f.root_hash
assert root1 != root2
assert f.merkle_verify() is None
with raf.open(st, key, c, merkle=True) as f:
f.verify_root(root2) # rebuilds and compares
assert f.root_hash == root2
with pytest.raises(ValueError, match="authentication failed"):
f.verify_root(root1)
with pytest.raises(ValueError):
f.verify_root(b"short")
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_merkle_detects_corruption(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK, merkle=True) as f:
f.write(b"z" * 3000)
f.merkle_rebuild()
record = c.NONCEBYTES + CHUNK + 16
# corrupt chunk 2's ciphertext in the backing store
st._data[raf.HEADER_SIZE + 2 * record + c.NONCEBYTES + 5] ^= 1
assert f.merkle_verify() == 2
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_merkle_overflow(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(
st, key, c, chunk_size=CHUNK, merkle=True, merkle_max_chunks=2
) as f:
f.write(b"a" * 2048)
with pytest.raises(ValueError, match="merkle_max_chunks"):
f.write(b"b")
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_merkle_disabled(c):
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
assert f.root_hash is None
with pytest.raises(ValueError, match="not enabled"):
f.merkle_verify()
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_custom_rng(c):
counter = 0
def deterministic(n: int) -> bytes:
nonlocal counter
out = bytes((counter + i) % 256 for i in range(n))
counter += n
return out
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK, rng=deterministic) as f:
f.write(b"rng test")
with raf.open(st, key, c) as f:
assert f.read() == b"rng test"
assert counter > 0
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_default_rng_matches_cipher(c):
"""raf.create/open without rng use a Random seeded for the same cipher."""
key = c.random_key()
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
assert f._rng_keepalive[0] # handle alive
f.write(b"default rng")
with raf.open(st, key, c) as f:
assert f.read() == b"default rng"
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_random_per_cipher(c):
"""Random can be constructed with each cipher module."""
r1 = aeg_random.Random(c)
r2 = c.random()
out = r1.bytes(100)
assert len(out) == 100
assert any(out) # not all zeros
assert len(r2.bytes(50)) == 50
# Random instance satisfies the RandomSource callable protocol
assert callable(r1)
rng_cdata, keepalive = aeg_random.as_raf_rng(r1)
assert rng_cdata.random is not None and keepalive
def test_random_determinism_with_seed():
"""Two Random instances seeded identically produce identical output."""
c = aegis256x4
seed = bytes(range(c.NONCEBYTES + c.KEYBYTES))
r1 = aeg_random.Random(c, seed=seed)
r2 = aeg_random.Random(c, seed=seed)
assert r1.bytes(64) == r2.bytes(64)
assert r1.bytes(1000) == r2.bytes(1000)
with pytest.raises(TypeError, match="seed length"):
aeg_random.Random(c, seed=b"short")
def test_random_into_equivalence():
"""into() fills in place and matches bytes() from an identically-seeded RNG."""
c = aegis256x4
seed = bytes(c.NONCEBYTES + c.KEYBYTES)
r1 = aeg_random.Random(c, seed=seed)
r2 = aeg_random.Random(c, seed=seed)
buf = bytearray(128)
r1.into(buf)
assert bytes(buf) == bytes(r2.bytes(128))
# buffers of other buffer-protocol types work too
import array
arr = array.array("b", bytes(32))
r1.into(arr)
assert any(arr.tobytes())
def test_random_no_repeat():
"""Successive calls never repeat a block (nonce increments)."""
r = aeg_random.Random(aegis128x2)
blocks = {bytes(r.bytes(64)) for _ in range(1000)}
assert len(blocks) == 1000
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_cipher_module_random_helper(c):
"""Each cipher module provides a random() helper bound to itself."""
r = c.random()
assert isinstance(r, aeg_random.Random)
assert r._cipher is c
assert len(r.bytes(32)) == 32
buf = bytearray(48)
r.into(buf)
assert any(buf)
def test_random_module():
assert len(aeg_random.system_random(16)) == 16
# any callable source can be bridged to the C RNG struct
rng, keepalive = aeg_random.as_raf_rng(aeg_random.system_random)
assert rng.random is not None and keepalive
def bad(n: int) -> bytes:
return b"too short"
rng2, _ = aeg_random.as_raf_rng(bad)
out = aeg_random.ffi.new("uint8_t[8]")
assert rng2.random(rng2.user, out, 8) == -1
def test_derive_master_key():
mk16 = bytes(range(16))
mk32 = bytes(range(32))
assert len(raf.derive_master_key(mk16)) == 16
assert len(raf.derive_master_key(mk32)) == 32
# context-bound: different contexts give different keys
assert raf.derive_master_key(mk32, b"a") != raf.derive_master_key(mk32, b"b")
# empty context still derives a scoped subkey
assert raf.derive_master_key(mk32) != mk32
with pytest.raises(ValueError):
raf.derive_master_key(b"\x00" * 24)
with pytest.raises(ValueError):
raf.derive_master_key(mk16, b"c" * 121)
with pytest.raises(ValueError):
raf.derive_master_key(mk32, b"c" * 73)
# derived keys work end-to-end
c = aegis256x4
key = raf.derive_master_key(mk32, b"my-file")
st = raf.BytesIOStorage()
with raf.create(st, key, c, chunk_size=CHUNK) as f:
f.write(b"derived")
with raf.open(st, key, c) as f:
assert f.read() == b"derived"
def test_invalid_chunk_size():
c = aegis256x4
with pytest.raises(ValueError, match="chunk_size"):
raf.create(raf.BytesIOStorage(), c.random_key(), c, chunk_size=512)
with pytest.raises(ValueError, match="multiple of 16"):
raf.create(raf.BytesIOStorage(), c.random_key(), c, chunk_size=1032)
def test_bad_key_length():
c = aegis256x4
with pytest.raises(TypeError, match="key length"):
raf.create(raf.BytesIOStorage(), b"\x00" * 16, c, chunk_size=CHUNK)
def test_closed_file():
c = aegis256x4
st = raf.BytesIOStorage()
f = raf.create(st, c.random_key(), c, chunk_size=CHUNK)
f.close()
assert f.closed
with pytest.raises(ValueError, match="closed"):
f.read()
f.close() # idempotent
File diff suppressed because it is too large. Load diff
+339
View File
@@ -0,0 +1,339 @@
"""Tests for Encryptor and Decryptor finalization behavior.
This module verifies that Encryptor and Decryptor objects become unusable
after calling final(), preventing accidental misuse.
"""
import pytest
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
# All AEGIS algorithm modules
ALL_ALGORITHMS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
class TestMacFinalization:
"""Test that Mac becomes unusable after final()."""
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_update_after_final_raises(self, alg):
"""Test that calling update() after final() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.final()
# Attempting to update after final should raise RuntimeError
with pytest.raises(RuntimeError, match="Cannot update after final\\(\\)"):
mac.update(b"More data")
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_final_after_final_raises(self, alg):
"""Test that calling final() after final() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.final()
# Attempting to call final again should raise RuntimeError
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.final()
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_digest_after_final_raises(self, alg):
"""Test that digest() and hexdigest() raise after final()."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.final()
# digest() should raise after final()
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.digest()
# hexdigest() should also raise after final()
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.hexdigest()
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_update_after_digest_raises(self, alg):
"""Test that calling update() after digest() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.digest()
# Attempting to update after digest should raise RuntimeError
with pytest.raises(RuntimeError, match="Cannot update after final\\(\\)"):
mac.update(b"More data")
@pytest.mark.parametrize(
"alg", ALL_ALGORITHMS, ids=lambda x: x.__name__.split(".")[-1]
)
def test_final_after_digest_raises(self, alg):
"""Test that calling final() after digest() raises RuntimeError."""
key = alg.random_key()
nonce = alg.random_nonce()
mac = alg.Mac(key, nonce)
mac.update(b"Hello, world!")
mac.digest()
# Attempting to call final after digest should raise RuntimeError
with pytest.raises(RuntimeError, match="The MAC can only be calculated once"):
mac.final()
class TestEncryptorFinalization:
"""Test that Encryptor becomes unusable after final()."""
def test_update_after_final_raises(self):
"""Test that calling update() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Encrypt some data and finalize
encryptor.update(b"Hello, world!")
encryptor.final()
# Attempting to update after final should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
encryptor.update(b"More data")
def test_final_after_final_raises(self):
"""Test that calling final() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Encrypt some data and finalize
encryptor.update(b"Hello, world!")
encryptor.final()
# Attempting to call final again should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
encryptor.final()
def test_update_then_final_after_final_raises(self):
"""Test that both update() and final() fail after final()."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Encrypt and finalize
encryptor.update(b"Test data")
encryptor.final()
# Both operations should fail
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
encryptor.update(b"More data")
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
encryptor.final()
def test_empty_encryption_finalization(self):
"""Test that finalization works correctly with no update() calls."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
encryptor = aegis256x4.Encryptor(key, nonce)
# Finalize without any updates
tag = encryptor.final()
assert len(tag) == aegis256x4.MACBYTES
# Should still be unusable after
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
encryptor.update(b"Data")
class TestDecryptorFinalization:
"""Test that Decryptor becomes unusable after final()."""
def test_update_after_final_raises(self):
"""Test that calling update() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Hello, world!"
# Encrypt first to get valid ciphertext and tag
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Now test decryption
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
decryptor.final(tag)
# Attempting to update after final should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
decryptor.update(b"More ciphertext")
def test_final_after_final_raises(self):
"""Test that calling final() after final() raises RuntimeError."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Hello, world!"
# Encrypt first to get valid ciphertext and tag
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Now test decryption
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
decryptor.final(tag)
# Attempting to call final again should raise RuntimeError
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
decryptor.final(tag)
def test_update_then_final_after_final_raises(self):
"""Test that both update() and final() fail after final()."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
message = b"Test data"
# Encrypt first
ct, tag = aegis256x4.encrypt_detached(key, nonce, message)
# Decrypt and finalize
decryptor = aegis256x4.Decryptor(key, nonce)
decryptor.update(ct)
decryptor.final(tag)
# Both operations should fail
with pytest.raises(
RuntimeError, match="Cannot call update\\(\\) after final\\(\\)"
):
decryptor.update(b"More ciphertext")
with pytest.raises(
RuntimeError, match="Cannot call final\\(\\) after final\\(\\)"
):
decryptor.final(tag)
def test_empty_decryption_finalization(self):
"""Test that finalization works correctly with no update() calls."""
key = aegis256x4.random_key()
nonce = aegis256x4.random_nonce()
# Encrypt empty message
ct, tag = aegis256x4.encrypt_detached(key, nonce, b"")
del ct
# 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")
+56
View File
@@ -0,0 +1,56 @@
import threading
import pytest
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
CIPHERS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
def cipher_id(c):
return c.NAME
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_random_is_per_thread_singleton(c):
assert c.random() is c.random()
other = []
t = threading.Thread(target=lambda: other.append(c.random()))
t.start()
t.join()
assert other[0] is not c.random()
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_random_key_nonce_from_csprng(c):
assert len(c.random_key()) == c.KEYBYTES
assert len(c.random_nonce()) == c.NONCEBYTES
# successive outputs never repeat
assert c.random_key() != c.random_key()
assert c.random_nonce() != c.random_nonce()
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_threads_get_independent_streams(c):
results = [None] * 2
def work(i):
results[i] = bytes(c.random_key())
threads = [threading.Thread(target=work, args=(i,)) for i in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert results[0] != results[1]
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_bytes_into_callable(c):
rng = c.random()
a = rng.bytes(64)
buf = bytearray(64)
rng.into(buf)
b = rng(64)
assert len(a) == len(b) == 64
assert a != bytes(buf) != b # three consecutive blocks differ
+106
View File
@@ -0,0 +1,106 @@
"""Tests for aeg.securebuf.SecureBuffer.
CPython-only assumptions: reference counting frees memoryviews as soon as
they are del'd; live exported views would make mmap.close() fail with
BufferError.
"""
import pytest
from aeg import aegis128l
from aeg.securebuf import SecureBuffer
from aeg.util import wipe
def test_buffer_protocol_read_write():
with SecureBuffer(32) as sb:
assert len(sb) == 32
mv = memoryview(sb)
assert len(mv) == 32
assert not mv.readonly
mv[:4] = b"abcd"
assert mv[:4] == b"abcd"
del mv
assert sb.closed
def test_not_convertible_to_bytes():
with SecureBuffer(16) as sb, pytest.raises(TypeError, match="cannot be converted"):
bytes(sb)
def test_invalid_size():
for size in (0, -1):
with pytest.raises(ValueError):
SecureBuffer(size)
def test_close_is_idempotent_and_blocks_access():
sb = SecureBuffer(16)
sb.close()
assert sb.closed
with pytest.raises(ValueError):
memoryview(sb)
sb.close()
assert sb.closed
def test_context_manager_closes_on_exception():
with pytest.raises(RuntimeError), SecureBuffer(16) as sb:
raise RuntimeError("boom")
assert sb.closed
def test_del_closes():
import gc
sb = SecureBuffer(16)
ref = sb._mmap
del sb
gc.collect()
assert ref.closed
def test_getitem_returns_memoryview():
with SecureBuffer(16) as sb:
sb[:4] = b"abcd"
view = sb[2:8]
assert isinstance(view, memoryview)
assert view.tobytes() == b"cd\x00\x00\x00\x00"
del view
def test_wipe_fills_ff():
sb = SecureBuffer(16)
sb[:] = b"A" * 16
wipe(sb)
mv = memoryview(sb)
assert mv.tobytes() == b"\xff" * 16
del mv
sb.close()
def test_roundtrip_as_key_and_into_target():
"""SecureBuffer works as key input and as an `into` target."""
c = aegis128l
with SecureBuffer(c.KEYBYTES) as key, SecureBuffer(c.NONCEBYTES) as nonce:
key[:] = bytes(range(c.KEYBYTES))
nonce[:] = bytes(c.NONCEBYTES)
msg = b"hello secure world"
ct, mac = c.encrypt_detached(key, nonce, msg)
with SecureBuffer(len(msg)) as pt:
out = c.decrypt_detached(key, nonce, ct, mac, into=pt)
assert len(out) == len(msg)
view = pt[:]
assert view.tobytes() == msg
del out, view
def test_stream_into():
c = aegis128l
key, nonce = c.random_key(), c.random_nonce()
with SecureBuffer(64) as buf:
c.stream(key, nonce, into=buf)
view = buf[:]
assert view.tobytes() == bytes(c.stream(key, nonce, 64))
del view
+54
View File
@@ -0,0 +1,54 @@
import pytest
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
CIPHERS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
def cipher_id(c):
return c.NAME
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_stream_xor_roundtrip(c):
key, nonce = c.random_key(), c.random_nonce()
msg = b"stream cipher test" * 100 # spans multiple blocks, unaligned tail
ct = c.stream_xor(key, nonce, msg)
assert isinstance(ct, bytearray)
assert len(ct) == len(msg)
assert ct != msg
# the same function decrypts
assert c.stream_xor(key, nonce, ct) == msg
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_stream_xor_is_stream_xor(c):
"""Output equals data XOR stream(key, nonce) keystream."""
key, nonce = c.random_key(), c.random_nonce()
msg = b"\x5a" * 500
ks = c.stream(key, nonce, len(msg))
ct = c.stream_xor(key, nonce, msg)
assert bytes(ct) == bytes(m ^ k for m, k in zip(msg, ks))
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_stream_xor_into(c):
key, nonce = c.random_key(), c.random_nonce()
msg = b"into target" * 10
buf = bytearray(len(msg) + 10)
out = c.stream_xor(key, nonce, msg, into=buf)
assert len(out) == len(msg)
assert bytes(out) == bytes(c.stream_xor(key, nonce, msg))
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
def test_unauthenticated_deprecated(c):
key, nonce = c.random_key(), c.random_nonce()
msg = b"legacy format" * 20 # longer than one block: absorbing mode diverges
with pytest.warns(DeprecationWarning):
ct = c.encrypt_unauthenticated(key, nonce, msg)
with pytest.warns(DeprecationWarning):
pt = c.decrypt_unauthenticated(key, nonce, ct)
assert pt == msg
# deprecated pair is NOT compatible with stream_xor beyond the first block
assert bytes(ct) != bytes(c.stream_xor(key, nonce, msg))
-1
View File
@@ -1,6 +1,5 @@
import random import random
_rng = random.SystemRandom() _rng = random.SystemRandom()
+46 -78
View File
@@ -1,100 +1,68 @@
"""Custom build backend that builds libaegis with Zig before building the Python package.""" """Custom build backend that builds libaegis with Zig before building the Python package."""
import os
import platform
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from setuptools import build_meta as _orig from setuptools import build_meta
__all__ = [
"build_sdist",
"build_wheel",
"build_editable",
"get_requires_for_build_sdist",
"get_requires_for_build_wheel",
"prepare_metadata_for_build_wheel",
]
_MACOS_TARGET = "11.0"
_prepared = False
def _check_zig_available(): def _prepare():
"""Check if Zig is installed and available.""" """Prepare the build environment and build libaegis."""
global _prepared
if _prepared:
return
_prepared = True
# Set macOS deployment target
if sys.platform == "darwin" and "MACOSX_DEPLOYMENT_TARGET" not in os.environ:
os.environ["MACOSX_DEPLOYMENT_TARGET"] = _MACOS_TARGET
# Check Zig is available
if shutil.which("zig") is None: if shutil.which("zig") is None:
raise RuntimeError( raise RuntimeError(
"\n" + "=" * 70 + "\n" "Zig compiler not found. Install from https://ziglang.org/download/"
"ERROR: Zig compiler not found!\n"
"\n"
"Building pyaegis requires the Zig compiler to build the libaegis\n"
"static library. Please install Zig before building this package.\n"
"\n"
"Installation instructions:\n"
" - Visit: https://ziglang.org/download/\n"
" - Or use a package manager:\n"
" * macOS: brew install zig\n"
" * Linux: See https://github.com/ziglang/zig/wiki/Install-Zig-from-a-Package-Manager\n"
" * Windows: choco install zig or scoop install zig\n"
"\n"
"After installing Zig, please try building again.\n" + "=" * 70 + "\n"
) )
# Build libaegis
def _build_libaegis():
"""Build libaegis static library with Zig."""
# Check Zig availability first
_check_zig_available()
libaegis_dir = Path(__file__).parent.parent / "libaegis" libaegis_dir = Path(__file__).parent.parent / "libaegis"
if not libaegis_dir.exists(): cmd = ["zig", "build", "-Drelease"]
raise FileNotFoundError( if sys.platform == "darwin":
f"libaegis directory not found at {libaegis_dir}. " arch = {"arm64": "aarch64", "x86_64": "x86_64"}.get(platform.machine())
"Cannot build static library." if arch:
) cmd.append(f"-Dtarget={arch}-macos.{_MACOS_TARGET}")
subprocess.run(cmd, cwd=libaegis_dir, check=True)
print("Building libaegis static library with Zig...")
try:
subprocess.run(
["zig", "build", "-Drelease"],
cwd=libaegis_dir,
check=True,
capture_output=False,
)
print("Successfully built libaegis static library")
except subprocess.CalledProcessError as e:
print(
f"\nError: Zig build failed with exit code {e.returncode}\n"
f"Command: {' '.join(e.cmd)}\n",
file=sys.stderr,
)
raise
# Expose all the standard build backend hooks build_sdist = build_meta.build_sdist
def get_requires_for_build_wheel(config_settings=None): get_requires_for_build_sdist = build_meta.get_requires_for_build_sdist
"""Return build requirements and ensure libaegis is built first.""" get_requires_for_build_wheel = build_meta.get_requires_for_build_wheel
_build_libaegis() prepare_metadata_for_build_wheel = build_meta.prepare_metadata_for_build_wheel
return _orig.get_requires_for_build_wheel(config_settings)
def get_requires_for_build_sdist(config_settings=None):
"""Return build requirements for sdist and ensure libaegis is built first."""
_build_libaegis()
return _orig.get_requires_for_build_sdist(config_settings)
_orig_prepare_metadata_for_build_wheel = _orig.prepare_metadata_for_build_wheel
_orig_build_sdist = _orig.build_sdist
def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
"""Prepare metadata and ensure libaegis is built (some frontends call this early)."""
_build_libaegis()
return _orig_prepare_metadata_for_build_wheel(metadata_directory, config_settings)
def build_sdist(sdist_directory, config_settings=None):
"""Build sdist, building libaegis first so the sdist can include built artifacts if needed."""
_build_libaegis()
return _orig_build_sdist(sdist_directory, config_settings)
# Wheel build hooks - need libaegis built first
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
"""Build wheel with libaegis built first.""" _prepare()
_build_libaegis() return build_meta.build_wheel(wheel_directory, config_settings, metadata_directory)
return _orig.build_wheel(wheel_directory, config_settings, metadata_directory)
def build_editable(wheel_directory, config_settings=None, metadata_directory=None): def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
"""Build editable install with libaegis built first.""" _prepare()
_build_libaegis() return build_meta.build_editable(
return _orig.build_editable(wheel_directory, config_settings, metadata_directory) wheel_directory, config_settings, metadata_directory
)
-168
View File
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""Generate CFFI cdef string from libaegis headers.
This script parses the C header files and extracts function declarations,
typedefs, and struct definitions to generate the cdef() string needed by CFFI.
"""
import pathlib
import re
import sys
def preprocess_content(content: str) -> str:
"""Remove comments, preprocessor directives, and extern "C" blocks."""
# Remove multi-line comments
content = re.sub(r"/\*.*?\*/", " ", content, flags=re.DOTALL)
# Remove line comments
content = re.sub(r"//.*$", "", content, flags=re.MULTILINE)
# Remove preprocessor directives
content = re.sub(r"^\s*#.*$", "", content, flags=re.MULTILINE)
# Remove extern "C" blocks
content = re.sub(r'extern\s+"C"\s*\{', "", content)
content = re.sub(r"(?:^|\n)\s*\}\s*(?:\n|$)", "\n", content, flags=re.MULTILINE)
return content
def clean_declaration(text: str) -> str:
"""Clean up a C declaration for CFFI consumption."""
# Remove __attribute__(...) with proper nesting
while "__attribute__" in text:
old = text
text = re.sub(r"__attribute__\s*\(\([^()]*\)\)", "", text)
if text == old:
break
# For structs with CRYPTO_ALIGN, replace the field with "...;" to make it flexible
# This tells CFFI to use the C compiler's alignment instead of calculating it
if "CRYPTO_ALIGN" in text and "typedef struct" in text:
# Replace "CRYPTO_ALIGN(N) uint8_t opaque[SIZE];" with "...;"
text = re.sub(
r"CRYPTO_ALIGN\s*\(\s*\d+\s*\)\s+uint8_t\s+opaque\[\d+\];", "...;", text
)
else:
# For non-struct declarations, just remove CRYPTO_ALIGN
text = re.sub(r"CRYPTO_ALIGN\s*\(\s*\d+\s*\)", "", text)
# Normalize whitespace but preserve structure
lines = []
for line in text.split("\n"):
line = re.sub(r"\s+", " ", line).strip()
if line:
lines.append(line)
return " ".join(lines)
def extract_declarations(header_path: pathlib.Path) -> list[str]:
"""Extract function declarations and typedefs from a header file."""
content = header_path.read_text(encoding="utf-8")
content = preprocess_content(content)
declarations = []
# Extract typedefs (including structs)
typedef_pattern = r"typedef\s+struct\s+\w+\s*\{[^}]+\}\s*\w+\s*;"
for match in re.finditer(typedef_pattern, content, re.DOTALL):
decl = clean_declaration(match.group(0))
if decl:
declarations.append(decl)
# Extract function declarations - more permissive pattern
func_pattern = r"((?:const\s+)?(?:int|void|size_t)\s+\w+\s*\([^;]+?\)\s*;)"
for match in re.finditer(func_pattern, content, re.DOTALL):
decl = clean_declaration(match.group(0))
if decl and "aegis" in decl.lower():
declarations.append(decl)
return declarations
def format_declaration(decl: str, max_width: int = 100) -> str:
"""Format a declaration for readability, with intelligent line breaking."""
# If it's short enough, return as-is
if len(decl) <= max_width:
return decl
# For function declarations, try to break at parameter boundaries
if "(" in decl and ")" in decl:
# Find the function name and opening paren
match = re.match(r"(.*?\s+\w+\s*)\((.*)\)(.*)", decl)
if match:
prefix, params, suffix = match.groups()
# Break parameters if they're too long
if len(prefix) + len(params) + 2 > max_width:
# Split parameters
param_list = [p.strip() for p in params.split(",")]
if len(param_list) > 1:
formatted_params = (",\n" + " " * (len(prefix) + 1)).join(
param_list
)
return f"{prefix}({formatted_params}){suffix}"
return decl
def generate_cdef(include_dir: pathlib.Path) -> str:
"""Generate the complete CFFI cdef string from all aegis headers."""
lines = [
"/* This file is generated with tools/gen_cdef.py. Do not edit. */",
"",
"typedef unsigned char uint8_t;",
"typedef unsigned long size_t;",
"",
]
# Header files in order, skipping aegis.h as it might be included elsewhere
headers = [
"aegis.h",
"aegis128l.h",
"aegis128x2.h",
"aegis128x4.h",
"aegis256.h",
"aegis256x2.h",
"aegis256x4.h",
]
for header_name in headers:
header_path = include_dir / header_name
if not header_path.exists():
print(f"Warning: {header_name} not found", file=sys.stderr)
continue
lines.append(f"/* {header_name} */")
declarations = extract_declarations(header_path)
for decl in declarations:
formatted = format_declaration(decl)
lines.append(formatted)
lines.append("")
return "\n".join(lines)
def main() -> int:
# Find the include directory
root = pathlib.Path(__file__).parent.parent
include_dir = root / "libaegis" / "src" / "include"
if not include_dir.exists():
print(f"Include directory not found: {include_dir}", file=sys.stderr)
return 1
cdef_string = generate_cdef(include_dir)
# Write to a file in the pyaegis directory
output_dir = root / "pyaegis"
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "aegis_cdef.h"
output_path.write_text(cdef_string, encoding="utf-8")
print(f"Generated: {output_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""
Regenerate aegis*.py modules from the canonical template aegis256x4.py.
Changes per variant:
- Replace module name (aegis256x4 -> target)
- Replace label (AEGIS-256X4 -> target label like AEGIS-128L)
- Replace only the ALIGNMENT = <int> value
- Replace only the RATE = <int> value
We do not touch alloc_aligned(...) calls or any code formatting. Blank lines
after ALIGNMENT are preserved.
"""
import pathlib
import re
import sys
# Template and target locations
ROOT = pathlib.Path(__file__).parent.parent
AEGIS_DIR = ROOT / "pyaegis"
TEMPLATE = AEGIS_DIR / "aegis256x4.py"
# Variants to generate (template excluded) and their ALIGNMENT values
VARIANT_ALIGN = {
"aegis256": 16,
"aegis256x2": 32,
"aegis256x4": 64,
"aegis128l": 32,
"aegis128x2": 64,
"aegis128x4": 64,
}
# Variants and their RATE values
VARIANT_RATE = {
"aegis256": 16,
"aegis256x2": 32,
"aegis256x4": 64,
"aegis128l": 32,
"aegis128x2": 64,
"aegis128x4": 128,
}
TEMPLATE_NAME = "aegis256x4"
TEMPLATE_LABEL = "AEGIS-256X4"
ALIGNMENT_LINE_RE = re.compile(r"^(ALIGNMENT\s*=\s*)(\d+)(\s*)$", re.MULTILINE)
RATE_LINE_RE = re.compile(r"^(RATE\s*=\s*)(\d+)(\s*)$", re.MULTILINE)
def set_alignment_only(text: str, value: int) -> str:
"""Replace only the numeric ALIGNMENT value, preserving surrounding whitespace and lines.
This preserves any empty lines following the ALIGNMENT assignment because
the line ending is not part of the match; we keep any trailing spaces too.
"""
def _sub(m: re.Match[str]) -> str:
prefix, _num, suffix = m.group(1), m.group(2), m.group(3)
return f"{prefix}{value}{suffix}"
return ALIGNMENT_LINE_RE.sub(_sub, text)
def set_rate_only(text: str, value: int) -> str:
"""Replace only the numeric RATE value, preserving surrounding whitespace and lines.
This preserves any empty lines following the RATE assignment because
the line ending is not part of the match; we keep any trailing spaces too.
"""
def _sub(m: re.Match[str]) -> str:
prefix, _num, suffix = m.group(1), m.group(2), m.group(3)
return f"{prefix}{value}{suffix}"
return RATE_LINE_RE.sub(_sub, text)
def algo_label(name: str) -> str:
"""Return the canonical label like AEGIS-256X4 for a module name like aegis256x4."""
if not name.startswith("aegis"):
raise ValueError(f"Unexpected algorithm name: {name}")
return "AEGIS-" + name[5:].upper()
def generate_variant(template_src: str, variant: str) -> str:
# 1) replace lowercase template name
s = template_src.replace(TEMPLATE_NAME, variant)
# 2) replace uppercase label
s = s.replace(TEMPLATE_LABEL, algo_label(variant))
# 3) set ALIGNMENT constant value using fallback map
align_value = VARIANT_ALIGN.get(variant, 64)
s = set_alignment_only(s, align_value)
# 4) set RATE constant value using fallback map
rate_value = VARIANT_RATE.get(variant, 64)
s = set_rate_only(s, rate_value)
return s
def main() -> int:
if not TEMPLATE.exists():
print(f"Template not found: {TEMPLATE}", file=sys.stderr)
return 2
template_src = TEMPLATE.read_text(encoding="utf-8")
# Safety: ensure we are working from an up-to-date template that contains expected tokens
if TEMPLATE_NAME not in template_src or TEMPLATE_LABEL not in template_src:
print(
"Template file does not contain expected identifiers; aborting.",
file=sys.stderr,
)
return 3
wrote = []
for variant in VARIANT_ALIGN.keys():
# Skip the template itself; recreate all other modules
if variant == TEMPLATE_NAME:
continue
dst = AEGIS_DIR / f"{variant}.py"
content = generate_variant(template_src, variant)
dst.write_text(content, encoding="utf-8")
wrote.append(dst.relative_to(ROOT))
print("Generated modules:")
for p in wrote:
print(" -", p)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env -S uv run
"""Generate CFFI cdef and Python modules from libaegis C sources."""
import pathlib
import re
import subprocess
import sys
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
and (match := re.match(r"(.*?\s+\w+\s*)\((.*)\)(.*)", decl))
):
prefix, params, suffix = match.groups()
if (
len(prefix) + len(params) + 2 > max_width
and len(param_list := [p.strip() for p in params.split(",")]) > 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:
# cffi pre-defines uint8_t, size_t, etc. with the correct platform sizes.
# Do not typedef them here: "unsigned long" matches size_t on LP64 but is
# 4 bytes vs 8 on Win64 (LLP64), breaking struct layout verification.
lines = [
"/* This file is generated with tools/generate.py. Do not edit. */",
"",
]
headers = [
"aegis.h",
"aegis128l.h",
"aegis128x2.h",
"aegis128x4.h",
"aegis256.h",
"aegis256x2.h",
"aegis256x4.h",
"aegis_raf.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, raf_h_path: pathlib.Path
) -> dict[str, int]:
"""Extract constants from common.h (ALIGNMENT, RATE), main header (KEYBYTES, NPUBBYTES, ABYTES_*), and aegis_raf.h (RAF_ALG_ID)."""
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))
# Extract RAF algorithm id from aegis_raf.h
raf_content = raf_h_path.read_text(encoding="utf-8")
raf_pattern = rf"^\s*#define\s+AEGIS_RAF_ALG_{variant[5:].upper()}\s+(\d+)"
raf_match = re.search(raf_pattern, raf_content, re.MULTILINE)
if not raf_match:
raise ValueError(f"Could not extract RAF_ALG_ID from {raf_h_path}")
constants["RAF_ALG_ID"] = int(raf_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"
raf_h = include_dir / "aegis_raf.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, raf_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,
)
s = re.sub(
r"RAF_ALG_ID = \d+",
f"RAF_ALG_ID = {constants['RAF_ALG_ID']}",
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,
)
new_content = re.sub(
r"RAF_ALG_ID = \d+",
f"RAF_ALG_ID = {const_dict['RAF_ALG_ID']}",
new_content,
)
else:
new_content = generate_variant(template_src, variant, const_dict)
if dst.exists() and dst.read_text(encoding="utf-8") == new_content:
unchanged.append(dst)
else:
dst.write_bytes(new_content.encode())
updated.append(dst)
return updated, unchanged
def generate_ciphers_module(constants: dict[str, dict[str, int]]) -> str:
labels = [algo_label(variant) for variant in constants]
literal_items = ", ".join(f'"{label}"' for label in labels)
lines = [
"# This file is generated by tools/generate.py. Do not edit.",
"from typing import Literal",
"",
f"CipherName = Literal[{literal_items}]",
"",
"CIPHERS: dict[CipherName, str] = {",
]
for variant in constants:
lines.append(f' "{algo_label(variant)}": "{variant}",')
lines.append("}")
return "\n".join(lines) + "\n"
def main() -> int:
root = pathlib.Path(__file__).parent.parent
libaegis_src_dir = root / "libaegis" / "src"
include_dir = libaegis_src_dir / "include"
pyaegis_dir = root / "src" / "aeg"
if not include_dir.exists():
print(f"Include directory not found: {include_dir}", file=sys.stderr)
return 1
if not libaegis_src_dir.exists():
print(f"Source directory not found: {libaegis_src_dir}", file=sys.stderr)
return 1
print("Step 1: Extracting constants from C sources...", file=sys.stderr)
constants = extract_all_constants(libaegis_src_dir, include_dir)
if not constants:
print("Error: No constants extracted", file=sys.stderr)
return 1
print("Step 2: Generating CFFI cdef header...", file=sys.stderr)
pyaegis_dir.mkdir(exist_ok=True)
cdef_path = pyaegis_dir / "aegis_cdef.h"
cdef_content = generate_cdef(include_dir)
if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content:
print(f" - No changes to {cdef_path}", file=sys.stderr)
else:
cdef_path.write_bytes(cdef_content.encode())
print(f" - Updated {cdef_path}", file=sys.stderr)
print("Step 3: Generating _ciphers.py...", file=sys.stderr)
ciphers_path = pyaegis_dir / "_ciphers.py"
ciphers_content = generate_ciphers_module(constants)
if (
ciphers_path.exists()
and ciphers_path.read_text(encoding="utf-8") == ciphers_content
):
print(f" - No changes to {ciphers_path.name}", file=sys.stderr)
else:
ciphers_path.write_bytes(ciphers_content.encode())
print(f" - Updated {ciphers_path.name}", file=sys.stderr)
print("Step 4: Generating Python modules...", file=sys.stderr)
try:
updated, unchanged = generate_python_modules(
pyaegis_dir / "aegis256x4.py", pyaegis_dir, constants
)
if updated:
for p in updated:
print(f" - {p.relative_to(root)}", file=sys.stderr)
if unchanged:
print(
" - No changes to",
f"{len(unchanged)} modules"
if len(unchanged) > 1
else unchanged[0].name,
file=sys.stderr,
)
except Exception as e:
print(f"Error generating Python modules: {e}", file=sys.stderr)
return 1
print("Step 5: Formatting generated files with ruff...", file=sys.stderr)
result = subprocess.run(
["uv", "run", "ruff", "format", str(pyaegis_dir)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print(f"Error formatting generated files: {result.stderr}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+485
View File
@@ -0,0 +1,485 @@
#!/usr/bin/env -S uv run
"""Build wheels for all supported Python versions using uv."""
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from packaging.version import Version
# Import generate module from same directory
sys.path.insert(0, str(Path(__file__).parent))
import generate
# Minimum macOS deployment target for compatibility
MACOS_DEPLOYMENT_TARGET = "11.0"
# ABI3 wheel: built once, works for all GIL-enabled Python versions
# We use a recent Python to build since it doesn't affect the wheel compatibility
ABI3_BUILD_VERSION = "3.14+gil"
# All GIL-enabled Python versions covered by the ABI3 wheel
ABI3_COVERED_VERSIONS = [
"3.12",
"3.13+gil",
"3.14+gil",
"3.15+gil",
]
# Non-ABI3 wheels: each needs its own build (free-threaded)
NON_ABI3_VERSIONS = [
"3.14t",
"3.15t",
]
# All versions for testing and benchmarking
ALL_PYTHON_VERSIONS = ABI3_COVERED_VERSIONS + NON_ABI3_VERSIONS
def get_version_from_scm():
"""Get version from setuptools-scm (git tags)."""
try:
result = subprocess.run(
["uv", "run", "-m", "setuptools_scm"],
capture_output=True,
text=True,
check=True,
cwd=Path(__file__).parent.parent,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"✗ Error getting version from setuptools-scm: {e}", file=sys.stderr)
return None
def is_release_version(version):
"""Check if version is a clean release (no dev/post/local identifiers)."""
# A release version is just x.y.z with optional alpha/beta/rc suffixes
# No +local or .devN or .postN
if not version:
return False
return not any(marker in version for marker in ["+", ".dev", ".post"])
def get_next_version(current_version):
"""Get the next release version from a dev version."""
# Parse base version (strips dev/local parts)
try:
v = Version(current_version)
return f"{v.major}.{v.minor}.{v.micro}"
except Exception:
return current_version
def is_working_copy_clean():
"""Check if git working copy is clean."""
result = subprocess.run(
["git", "status", "--porcelain"], capture_output=True, text=True, check=False
)
return result.returncode == 0 and not result.stdout.strip()
def make_release_message(version):
"""Generate message for making a release."""
next_version = get_next_version(version)
is_clean = is_working_copy_clean()
msg = "\n⚠️ This is not a clean release version; upload to PyPI skipped.\n\n"
msg += f"To create a release (e.g. {next_version}) and upload to PyPI:\n"
if not is_clean:
msg += " 1. Add and commit changes on the working copy\n"
msg += f" 2. Tag the commit: git tag v{next_version}\n"
msg += " 3. Run this script again\n"
msg += f" 4. Push the tag: git push origin v{next_version}\n"
else:
msg += f" 1. Tag the current commit: git tag v{next_version}\n"
msg += " 2. Run this script again\n"
msg += f" 3. Push the tag: git push origin v{next_version}\n"
msg += (
f"\nIf the build didn't work, delete the tag with git tag -d v{next_version}\n"
)
return msg
def run_command(cmd, description=None, env=None):
"""Run a command and handle errors. If description is None, only print the command."""
if description:
print(f"\n{'=' * 70}")
print(f"{description}")
print(f"{'=' * 70}")
print(f">>> {' '.join(cmd)}")
try:
subprocess.run(cmd, check=True, env=env)
return True
except subprocess.CalledProcessError as e:
print(f"✗ Command failed with exit code {e.returncode}", file=sys.stderr)
return False
def get_build_env():
"""Get environment variables for building wheels."""
env = os.environ.copy()
if platform.system() == "Darwin":
env["MACOSX_DEPLOYMENT_TARGET"] = MACOS_DEPLOYMENT_TARGET
return env
def normalize_line_endings(repo_root: Path):
"""Normalize all text files to LF line endings."""
# Patterns for files to normalize
patterns = [
"src/aeg/**/*.py",
"src/aeg/**/*.h",
"tests/**/*.py",
"tools/**/*.py",
"*.py",
"*.md",
"*.txt",
"*.toml",
"*.in",
]
for pattern in patterns:
for file_path in repo_root.glob(pattern):
if file_path.is_file():
content = file_path.read_bytes()
if b"\r\n" in content:
content = content.replace(b"\r\n", b"\n")
file_path.write_bytes(content)
def get_wheel_pattern(py_version: str, abi3: bool = False) -> str:
"""Get the glob pattern for finding a wheel file."""
if abi3:
# ABI3 wheels always use cp312-abi3 tag (minimum supported version)
# regardless of which Python version was used to build
return "aeg-*-cp312-abi3-*.whl"
elif py_version.endswith("t"):
# Free-threaded Python wheels use cpXXX-cpXXXt format (e.g., cp314-cp314t)
base_version = py_version.replace(".", "").replace("t", "")
return f"aeg-*-cp{base_version}-cp{base_version}t-*.whl"
else:
# Regular CPython wheels use cpXXX-cpXXX format
# Strip +gil suffix used to force non-free-threaded build
base_version = py_version.replace(".", "").replace("+gil", "")
return f"aeg-*-cp{base_version}-cp{base_version}-*.whl"
def build_abi3_wheel(dist_dir: Path, py_version: str) -> Path | None:
"""Build the ABI3 wheel using the specified Python version."""
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
if not run_command(cmd, env=get_build_env()):
return None
# Find the ABI3 wheel (always tagged cp312-abi3 regardless of build Python version)
wheel_pattern = get_wheel_pattern(py_version, abi3=True)
wheels = list(dist_dir.glob(wheel_pattern))
if not wheels:
print(f"✗ Could not find ABI3 wheel matching {wheel_pattern}", file=sys.stderr)
return None
wheel = wheels[0]
# Repair wheel with auditwheel for manylinux compatibility (Linux only)
if platform.system() == "Linux":
wheel = repair_wheel_linux(dist_dir, wheel, py_version, abi3=True)
if not wheel:
return None
return wheel
def build_wheel_for_version(dist_dir: Path, py_version: str) -> Path | None:
"""Build a wheel for a specific Python version (non-ABI3)."""
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
if not run_command(cmd, env=get_build_env()):
return None
# Find the wheel for this version
wheel_pattern = get_wheel_pattern(py_version, abi3=False)
wheels = list(dist_dir.glob(wheel_pattern))
if not wheels:
print(f"✗ Could not find wheel for Python {py_version}", file=sys.stderr)
return None
wheel = wheels[0]
# Repair wheel with auditwheel for manylinux compatibility (Linux only)
if platform.system() == "Linux":
wheel = repair_wheel_linux(dist_dir, wheel, py_version, abi3=False)
if not wheel:
return None
return wheel
def repair_wheel_linux(
dist_dir: Path, wheel: Path, py_version: str, abi3: bool
) -> Path | None:
"""Repair a wheel with auditwheel for manylinux compatibility (Linux only)."""
repair_cmd = [
"uv",
"run",
"auditwheel",
"repair",
str(wheel),
"-w",
str(dist_dir),
]
if not run_command(repair_cmd):
return None
# Find the repaired wheel (it will have a different name)
wheel_pattern = get_wheel_pattern(py_version, abi3=abi3)
all_wheels = list(dist_dir.glob(wheel_pattern))
repaired_wheels = [w for w in all_wheels if "linux_x86_64" not in str(w)]
if not repaired_wheels:
print(
f"✗ Could not find repaired (manylinux) wheel for Python {py_version}",
file=sys.stderr,
)
return None
repaired_wheel = repaired_wheels[0]
# Remove the unrepaired linux_x86_64 wheels
for w in all_wheels:
if "linux_x86_64" in str(w):
w.unlink()
return repaired_wheel
def test_wheel(wheel: Path, py_version: str) -> bool:
"""Test a wheel with pytest."""
# --isolated: avoid .venv conflicts
# --no-project: don't build from source in current directory, use the wheel
# --refresh-package: force uv to not use cached old versions
test_cmd = [
"uv",
"run",
"--isolated",
"--no-project",
"--refresh-package",
"aeg",
"--python",
py_version,
"--with",
str(wheel),
"--with",
"pytest",
"pytest",
"tests/",
]
return run_command(test_cmd)
def run_benchmark(wheel: Path, py_version: str) -> bool:
"""Run benchmark for a wheel."""
# --isolated: avoid .venv conflicts
# --no-project: don't build from source in current directory, use the wheel
# --refresh-package: force uv to not use cached old versions
bench_cmd = [
"uv",
"run",
"--isolated",
"--no-project",
"--refresh-package",
"aeg",
"--python",
py_version,
"--with",
str(wheel),
"-m",
"aeg.benchmark",
]
return run_command(bench_cmd)
return True
def main():
"""Build wheels for all supported Python versions."""
# CI matrix jobs build per-platform wheels but never upload; a separate
# publish job uploads everything once all platforms have succeeded.
no_upload = "--no-upload" in sys.argv
repo_root = Path(__file__).parent.parent
dist_dir = repo_root / "dist"
# Generate CFFI definitions and Python modules
print(f"\n{'=' * 70}")
print("Code generation from C headers (tools/generate.py)")
print(f"{'=' * 70}")
if generate.main() != 0:
print("✗ Code generation failed", file=sys.stderr)
return 1
# Run ruff to check and fix any issues
print(f"\n{'=' * 70}")
print("Linting and formatting")
print(f"{'=' * 70}")
if not run_command(["uv", "run", "ruff", "check", "--fix", "."]):
print("✗ Ruff check failed", file=sys.stderr)
return 1
# Run ruff format
if not run_command(["uv", "run", "ruff", "format", "."]):
print("✗ Ruff format failed", file=sys.stderr)
return 1
# Normalize all line endings to LF (important for consistent builds)
normalize_line_endings(repo_root)
# Get version from git repo
version = get_version_from_scm()
if not version:
return 1
is_release = is_release_version(version)
# Main header for the packaging process
print(f"\n{'=' * 70}")
print(
f"Packaging aeg-{version}"
+ (" for release" if is_release else " (not release)")
)
print(f"Building: 1 ABI3 wheel (for Python {', '.join(ABI3_COVERED_VERSIONS)})")
print(
f" + {len(NON_ABI3_VERSIONS)} non-ABI3 wheels ({', '.join(NON_ABI3_VERSIONS)})"
)
print(f"Testing/benchmarking: {len(ALL_PYTHON_VERSIONS)} Python versions")
print(f"Output directory: {dist_dir}", end=" ")
# Clean dist directory
if dist_dir.exists():
print("(wiped)")
shutil.rmtree(dist_dir)
else:
print("(created)")
# Clean build directory to remove stale CFFI-generated C code and .so files
build_dir = repo_root / "build"
if build_dir.exists():
print(f"Cleaning build directory: {build_dir}")
shutil.rmtree(build_dir)
# Build distributions
print(f"\n{'=' * 70}")
print("Building distributions")
print(f"{'=' * 70}")
# Build source distribution first
if not run_command(["uv", "build", "--sdist", "--quiet"], env=get_build_env()):
print("✗ Source distribution build failed", file=sys.stderr)
return 1
failed_builds = []
failed_tests = []
successful_wheels = []
wheel_for_version = {} # Map Python version to wheel path
# Build ABI3 wheel (once, works for all GIL-enabled versions)
abi3_wheel = build_abi3_wheel(dist_dir, ABI3_BUILD_VERSION)
if abi3_wheel:
successful_wheels.append(abi3_wheel)
# This wheel works for all ABI3-covered versions
for py_version in ABI3_COVERED_VERSIONS:
wheel_for_version[py_version] = abi3_wheel
else:
failed_builds.append(f"abi3 (built with {ABI3_BUILD_VERSION})")
# Build non-ABI3 wheels (free-threaded and PyPy)
for py_version in NON_ABI3_VERSIONS:
wheel = build_wheel_for_version(dist_dir, py_version)
if wheel:
successful_wheels.append(wheel)
wheel_for_version[py_version] = wheel
else:
failed_builds.append(py_version)
# Test and benchmark each Python version with its appropriate wheel
print(f"\n{'=' * 70}")
print("Testing and benchmarking")
print(f"{'=' * 70}")
for py_version in ALL_PYTHON_VERSIONS:
wheel = wheel_for_version.get(py_version)
if not wheel:
# No wheel available for this version (build failed)
continue
# Test the wheel with pytest
if not test_wheel(wheel, py_version):
failed_tests.append(py_version)
continue
# Run benchmark
if not run_benchmark(wheel, py_version):
failed_tests.append(py_version)
continue
# Summary
print(f"\n{'=' * 70}")
print("BUILD SUMMARY")
print(f"{'=' * 70}")
print(
f"Successful builds: sdist and {len(successful_wheels)} wheels "
f"(1 abi3 + {len(NON_ABI3_VERSIONS)} non-abi3)"
)
print(
f"Tests/benchmarks passed: {len(ALL_PYTHON_VERSIONS) - len(failed_tests) - len(failed_builds)}/{len(ALL_PYTHON_VERSIONS)} Python versions"
)
if failed_builds:
print(f"\nFailed builds: {len(failed_builds)}")
for failed_version in failed_builds:
print(f" ✗ {failed_version}")
if failed_tests:
print(f"\nFailed tests/benchmarks: {len(failed_tests)}")
for failed_version in failed_tests:
print(f" ✗ Python {failed_version}")
if not successful_wheels:
print("\n✗ No successful wheels to upload")
return 1
# List files to upload
sdist = list(dist_dir.glob("*.tar.gz"))
upload_files = sdist + successful_wheels
for file in upload_files:
print(f" - {file.name}")
# Only upload if this is a clean release version
if no_upload:
print("\n--no-upload given; upload to PyPI skipped.")
return 0
if not is_release:
print(make_release_message(version))
return 0
# Upload with twine
upload_cmd = ["uvx", "twine", "upload"] + [str(f) for f in upload_files]
if not run_command(upload_cmd, "Uploading to PyPI with twine"):
print("\n✗ Upload failed")
return 1
print(f"\n{'=' * 70}")
print("All builds and upload completed successfully!")
print(f"{'=' * 70}")
print()
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(1)