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.
This commit is contained in:
LeoVasanko committed 2026-10-04 04:25:59 +00:00
1 parent e70c7a37d2
commit e01d84a560
14 files changed
+5374 -3

No files matched your search

+35
View File
@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING: if TYPE_CHECKING:
from .random import Random
from .util import Buffer from .util import Buffer
__all__ = ["Cipher"] __all__ = ["Cipher"]
@@ -43,6 +44,8 @@ class Cipher(Protocol):
Encryptor: type[_Encryptor] Encryptor: type[_Encryptor]
Decryptor: type[_Decryptor] Decryptor: type[_Decryptor]
@staticmethod
def random() -> "Random": ...
@staticmethod @staticmethod
def random_key() -> bytearray: ... def random_key() -> bytearray: ...
@staticmethod @staticmethod
@@ -124,3 +127,35 @@ class Cipher(Protocol):
def nonce_increment(nonce: "Buffer") -> None: ... def nonce_increment(nonce: "Buffer") -> None: ...
@staticmethod @staticmethod
def wipe(buffer: "Buffer") -> None: ... 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: ...
+241
View File
@@ -4,10 +4,12 @@
import errno import errno
import secrets import secrets
import sys
from typing import Literal from typing import Literal
from ._loader import ffi from ._loader import ffi
from ._loader import lib as _lib from ._loader import lib as _lib
from .random import Random
from .util import Buffer, new_aligned_struct, nonce_increment, wipe from .util import Buffer, new_aligned_struct, nonce_increment, wipe
NAME = "AEGIS-128L" #: Algorithm display name NAME = "AEGIS-128L" #: Algorithm display name
@@ -19,6 +21,15 @@ ALIGNMENT = 64 #: Required alignment for internal structures
RATE = 64 #: Byte chunk size in internal processing RATE = 64 #: Byte chunk size in internal processing
def random() -> Random:
"""Return a new AEGIS stream Random instance using this cipher module.
Seeded once from the OS, then deterministic: successive calls never
repeat an output block. See aeg.random.Random for the API.
"""
return Random(sys.modules[__name__])
def random_key() -> bytearray: def random_key() -> bytearray:
""" """
Generate a secret key using cryptographically secure random bytes. Generate a secret key using cryptographically secure random bytes.
@@ -868,6 +879,220 @@ def new_mac_state():
return new_aligned_struct("aegis128l_mac_state", ALIGNMENT) return new_aligned_struct("aegis128l_mac_state", ALIGNMENT)
RAF_ALG_ID = 1 #: Algorithm identifier in RAF file headers
def _raise_errno(operation: str) -> None:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"{operation} failed: {err_name}")
def new_raf_ctx():
"""Allocate and return a new aegis128l_raf_ctx* with proper alignment."""
return new_aligned_struct("aegis128l_raf_ctx", ALIGNMENT)
def raf_scratch_size(chunk_size: int) -> int:
"""Return the scratch buffer size required for the given chunk_size."""
return _lib.aegis128l_raf_scratch_size(chunk_size)
def raf_create(key: Buffer, ctx, io, rng, config) -> None:
"""Create a new encrypted file, writing the header through io.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileExistsError: If the file exists and AEGIS_RAF_TRUNCATE was not set.
RuntimeError: If creation fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis128l_raf_create(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
if ffi.errno == errno.EEXIST:
raise FileExistsError("file exists (use truncate to overwrite)")
_raise_errno("raf create")
def raf_open(key: Buffer, ctx, io, rng, config) -> None:
"""Open an existing encrypted file, verifying the header MAC.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileNotFoundError: If the file does not exist.
ValueError: If the header is invalid or authentication fails (wrong key).
RuntimeError: If opening fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis128l_raf_open(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
err_num = ffi.errno
if err_num == errno.ENOENT:
raise FileNotFoundError("file not found")
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf open")
def raf_read(
ctx, length: int, offset: int, *, into: Buffer | None = None
) -> bytearray | memoryview:
"""Read and decrypt up to length bytes at offset.
Returns:
The decrypted bytes, trimmed to the number actually read
(fewer at EOF), as bytearray if into not provided,
memoryview of into otherwise.
Raises:
TypeError: If into is smaller than length.
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
if into is None:
out = bytearray(length)
else:
into = memoryview(into)
if into.nbytes < length:
raise TypeError("into length must be at least length")
out = into
bytes_read = ffi.new("size_t*")
rc = _lib.aegis128l_raf_read(
ctx.ptr, ffi.from_buffer(out), bytes_read, length, offset
)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf read")
n = bytes_read[0]
return out[:n] if into is None else memoryview(out)[:n] # type: ignore
def raf_write(ctx, data: Buffer, offset: int) -> int:
"""Encrypt and write data at offset, extending the file as needed.
Returns:
Number of bytes written.
Raises:
RuntimeError: If the write fails (errno name included).
"""
data = memoryview(data)
bytes_written = ffi.new("size_t*")
rc = _lib.aegis128l_raf_write(
ctx.ptr, bytes_written, _ptr(data), data.nbytes, offset
)
if rc != 0:
_raise_errno("raf write")
return bytes_written[0]
def raf_truncate(ctx, size: int) -> None:
"""Resize the file to size bytes (zero-filled when growing).
Raises:
RuntimeError: If the truncate fails.
"""
rc = _lib.aegis128l_raf_truncate(ctx.ptr, size)
if rc != 0:
_raise_errno("raf truncate")
def raf_size(ctx) -> int:
"""Return the logical plaintext file size."""
size = ffi.new("uint64_t*")
rc = _lib.aegis128l_raf_get_size(ctx.ptr, size)
if rc != 0:
_raise_errno("raf get_size")
return size[0]
def raf_sync(ctx) -> None:
"""Flush writes to the backing store.
Raises:
RuntimeError: If the sync fails.
"""
rc = _lib.aegis128l_raf_sync(ctx.ptr)
if rc != 0:
_raise_errno("raf sync")
def raf_close(ctx) -> None:
"""Close the context, syncing and zeroizing key material."""
_lib.aegis128l_raf_close(ctx.ptr)
def raf_merkle_rebuild(ctx) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Raises:
RuntimeError: If Merkle is not enabled or rebuilding fails.
ValueError: If a chunk fails authentication.
"""
rc = _lib.aegis128l_raf_merkle_rebuild(ctx.ptr)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf merkle_rebuild")
def raf_merkle_verify(ctx) -> 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.
Raises:
RuntimeError: If Merkle is not enabled or verification fails.
"""
corrupted = ffi.new("uint64_t*")
rc = _lib.aegis128l_raf_merkle_verify(ctx.ptr, corrupted)
if rc == 0:
return None
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
return corrupted[0]
_raise_errno("raf merkle_verify")
def raf_merkle_commitment(ctx, hash_len: int) -> bytes:
"""Compute the Merkle root commitment hash.
Returns:
The commitment hash as bytes of length hash_len.
Raises:
RuntimeError: If Merkle is not enabled or the call fails.
"""
out = ffi.new(f"uint8_t[{hash_len}]")
rc = _lib.aegis128l_raf_merkle_commitment(ctx.ptr, out, hash_len)
if rc != 0:
_raise_errno("raf merkle_commitment")
return bytes(ffi.buffer(out, hash_len))
__all__ = [ __all__ = [
# constants # constants
"NAME", "NAME",
@@ -877,7 +1102,9 @@ __all__ = [
"MACBYTES_LONG", "MACBYTES_LONG",
"ALIGNMENT", "ALIGNMENT",
"RATE", "RATE",
"RAF_ALG_ID",
# utility functions # utility functions
"random",
"random_key", "random_key",
"random_nonce", "random_nonce",
"nonce_increment", "nonce_increment",
@@ -895,4 +1122,18 @@ __all__ = [
"Encryptor", "Encryptor",
"Decryptor", "Decryptor",
"Mac", "Mac",
# random-access encrypted files (thin wrappers; see aeg.raf for the high-level API)
"new_raf_ctx",
"raf_scratch_size",
"raf_create",
"raf_open",
"raf_read",
"raf_write",
"raf_truncate",
"raf_size",
"raf_sync",
"raf_close",
"raf_merkle_rebuild",
"raf_merkle_verify",
"raf_merkle_commitment",
] ]
+241
View File
@@ -4,10 +4,12 @@
import errno import errno
import secrets import secrets
import sys
from typing import Literal from typing import Literal
from ._loader import ffi from ._loader import ffi
from ._loader import lib as _lib from ._loader import lib as _lib
from .random import Random
from .util import Buffer, new_aligned_struct, nonce_increment, wipe from .util import Buffer, new_aligned_struct, nonce_increment, wipe
NAME = "AEGIS-128X2" #: Algorithm display name NAME = "AEGIS-128X2" #: Algorithm display name
@@ -19,6 +21,15 @@ ALIGNMENT = 64 #: Required alignment for internal structures
RATE = 64 #: Byte chunk size in internal processing RATE = 64 #: Byte chunk size in internal processing
def random() -> Random:
"""Return a new AEGIS stream Random instance using this cipher module.
Seeded once from the OS, then deterministic: successive calls never
repeat an output block. See aeg.random.Random for the API.
"""
return Random(sys.modules[__name__])
def random_key() -> bytearray: def random_key() -> bytearray:
""" """
Generate a secret key using cryptographically secure random bytes. Generate a secret key using cryptographically secure random bytes.
@@ -868,6 +879,220 @@ def new_mac_state():
return new_aligned_struct("aegis128x2_mac_state", ALIGNMENT) return new_aligned_struct("aegis128x2_mac_state", ALIGNMENT)
RAF_ALG_ID = 2 #: Algorithm identifier in RAF file headers
def _raise_errno(operation: str) -> None:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"{operation} failed: {err_name}")
def new_raf_ctx():
"""Allocate and return a new aegis128x2_raf_ctx* with proper alignment."""
return new_aligned_struct("aegis128x2_raf_ctx", ALIGNMENT)
def raf_scratch_size(chunk_size: int) -> int:
"""Return the scratch buffer size required for the given chunk_size."""
return _lib.aegis128x2_raf_scratch_size(chunk_size)
def raf_create(key: Buffer, ctx, io, rng, config) -> None:
"""Create a new encrypted file, writing the header through io.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileExistsError: If the file exists and AEGIS_RAF_TRUNCATE was not set.
RuntimeError: If creation fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis128x2_raf_create(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
if ffi.errno == errno.EEXIST:
raise FileExistsError("file exists (use truncate to overwrite)")
_raise_errno("raf create")
def raf_open(key: Buffer, ctx, io, rng, config) -> None:
"""Open an existing encrypted file, verifying the header MAC.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileNotFoundError: If the file does not exist.
ValueError: If the header is invalid or authentication fails (wrong key).
RuntimeError: If opening fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis128x2_raf_open(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
err_num = ffi.errno
if err_num == errno.ENOENT:
raise FileNotFoundError("file not found")
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf open")
def raf_read(
ctx, length: int, offset: int, *, into: Buffer | None = None
) -> bytearray | memoryview:
"""Read and decrypt up to length bytes at offset.
Returns:
The decrypted bytes, trimmed to the number actually read
(fewer at EOF), as bytearray if into not provided,
memoryview of into otherwise.
Raises:
TypeError: If into is smaller than length.
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
if into is None:
out = bytearray(length)
else:
into = memoryview(into)
if into.nbytes < length:
raise TypeError("into length must be at least length")
out = into
bytes_read = ffi.new("size_t*")
rc = _lib.aegis128x2_raf_read(
ctx.ptr, ffi.from_buffer(out), bytes_read, length, offset
)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf read")
n = bytes_read[0]
return out[:n] if into is None else memoryview(out)[:n] # type: ignore
def raf_write(ctx, data: Buffer, offset: int) -> int:
"""Encrypt and write data at offset, extending the file as needed.
Returns:
Number of bytes written.
Raises:
RuntimeError: If the write fails (errno name included).
"""
data = memoryview(data)
bytes_written = ffi.new("size_t*")
rc = _lib.aegis128x2_raf_write(
ctx.ptr, bytes_written, _ptr(data), data.nbytes, offset
)
if rc != 0:
_raise_errno("raf write")
return bytes_written[0]
def raf_truncate(ctx, size: int) -> None:
"""Resize the file to size bytes (zero-filled when growing).
Raises:
RuntimeError: If the truncate fails.
"""
rc = _lib.aegis128x2_raf_truncate(ctx.ptr, size)
if rc != 0:
_raise_errno("raf truncate")
def raf_size(ctx) -> int:
"""Return the logical plaintext file size."""
size = ffi.new("uint64_t*")
rc = _lib.aegis128x2_raf_get_size(ctx.ptr, size)
if rc != 0:
_raise_errno("raf get_size")
return size[0]
def raf_sync(ctx) -> None:
"""Flush writes to the backing store.
Raises:
RuntimeError: If the sync fails.
"""
rc = _lib.aegis128x2_raf_sync(ctx.ptr)
if rc != 0:
_raise_errno("raf sync")
def raf_close(ctx) -> None:
"""Close the context, syncing and zeroizing key material."""
_lib.aegis128x2_raf_close(ctx.ptr)
def raf_merkle_rebuild(ctx) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Raises:
RuntimeError: If Merkle is not enabled or rebuilding fails.
ValueError: If a chunk fails authentication.
"""
rc = _lib.aegis128x2_raf_merkle_rebuild(ctx.ptr)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf merkle_rebuild")
def raf_merkle_verify(ctx) -> 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.
Raises:
RuntimeError: If Merkle is not enabled or verification fails.
"""
corrupted = ffi.new("uint64_t*")
rc = _lib.aegis128x2_raf_merkle_verify(ctx.ptr, corrupted)
if rc == 0:
return None
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
return corrupted[0]
_raise_errno("raf merkle_verify")
def raf_merkle_commitment(ctx, hash_len: int) -> bytes:
"""Compute the Merkle root commitment hash.
Returns:
The commitment hash as bytes of length hash_len.
Raises:
RuntimeError: If Merkle is not enabled or the call fails.
"""
out = ffi.new(f"uint8_t[{hash_len}]")
rc = _lib.aegis128x2_raf_merkle_commitment(ctx.ptr, out, hash_len)
if rc != 0:
_raise_errno("raf merkle_commitment")
return bytes(ffi.buffer(out, hash_len))
__all__ = [ __all__ = [
# constants # constants
"NAME", "NAME",
@@ -877,7 +1102,9 @@ __all__ = [
"MACBYTES_LONG", "MACBYTES_LONG",
"ALIGNMENT", "ALIGNMENT",
"RATE", "RATE",
"RAF_ALG_ID",
# utility functions # utility functions
"random",
"random_key", "random_key",
"random_nonce", "random_nonce",
"nonce_increment", "nonce_increment",
@@ -895,4 +1122,18 @@ __all__ = [
"Encryptor", "Encryptor",
"Decryptor", "Decryptor",
"Mac", "Mac",
# random-access encrypted files (thin wrappers; see aeg.raf for the high-level API)
"new_raf_ctx",
"raf_scratch_size",
"raf_create",
"raf_open",
"raf_read",
"raf_write",
"raf_truncate",
"raf_size",
"raf_sync",
"raf_close",
"raf_merkle_rebuild",
"raf_merkle_verify",
"raf_merkle_commitment",
] ]
+241
View File
@@ -4,10 +4,12 @@
import errno import errno
import secrets import secrets
import sys
from typing import Literal from typing import Literal
from ._loader import ffi from ._loader import ffi
from ._loader import lib as _lib from ._loader import lib as _lib
from .random import Random
from .util import Buffer, new_aligned_struct, nonce_increment, wipe from .util import Buffer, new_aligned_struct, nonce_increment, wipe
NAME = "AEGIS-128X4" #: Algorithm display name NAME = "AEGIS-128X4" #: Algorithm display name
@@ -19,6 +21,15 @@ ALIGNMENT = 64 #: Required alignment for internal structures
RATE = 64 #: Byte chunk size in internal processing RATE = 64 #: Byte chunk size in internal processing
def random() -> Random:
"""Return a new AEGIS stream Random instance using this cipher module.
Seeded once from the OS, then deterministic: successive calls never
repeat an output block. See aeg.random.Random for the API.
"""
return Random(sys.modules[__name__])
def random_key() -> bytearray: def random_key() -> bytearray:
""" """
Generate a secret key using cryptographically secure random bytes. Generate a secret key using cryptographically secure random bytes.
@@ -868,6 +879,220 @@ def new_mac_state():
return new_aligned_struct("aegis128x4_mac_state", ALIGNMENT) return new_aligned_struct("aegis128x4_mac_state", ALIGNMENT)
RAF_ALG_ID = 3 #: Algorithm identifier in RAF file headers
def _raise_errno(operation: str) -> None:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"{operation} failed: {err_name}")
def new_raf_ctx():
"""Allocate and return a new aegis128x4_raf_ctx* with proper alignment."""
return new_aligned_struct("aegis128x4_raf_ctx", ALIGNMENT)
def raf_scratch_size(chunk_size: int) -> int:
"""Return the scratch buffer size required for the given chunk_size."""
return _lib.aegis128x4_raf_scratch_size(chunk_size)
def raf_create(key: Buffer, ctx, io, rng, config) -> None:
"""Create a new encrypted file, writing the header through io.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileExistsError: If the file exists and AEGIS_RAF_TRUNCATE was not set.
RuntimeError: If creation fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis128x4_raf_create(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
if ffi.errno == errno.EEXIST:
raise FileExistsError("file exists (use truncate to overwrite)")
_raise_errno("raf create")
def raf_open(key: Buffer, ctx, io, rng, config) -> None:
"""Open an existing encrypted file, verifying the header MAC.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileNotFoundError: If the file does not exist.
ValueError: If the header is invalid or authentication fails (wrong key).
RuntimeError: If opening fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis128x4_raf_open(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
err_num = ffi.errno
if err_num == errno.ENOENT:
raise FileNotFoundError("file not found")
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf open")
def raf_read(
ctx, length: int, offset: int, *, into: Buffer | None = None
) -> bytearray | memoryview:
"""Read and decrypt up to length bytes at offset.
Returns:
The decrypted bytes, trimmed to the number actually read
(fewer at EOF), as bytearray if into not provided,
memoryview of into otherwise.
Raises:
TypeError: If into is smaller than length.
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
if into is None:
out = bytearray(length)
else:
into = memoryview(into)
if into.nbytes < length:
raise TypeError("into length must be at least length")
out = into
bytes_read = ffi.new("size_t*")
rc = _lib.aegis128x4_raf_read(
ctx.ptr, ffi.from_buffer(out), bytes_read, length, offset
)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf read")
n = bytes_read[0]
return out[:n] if into is None else memoryview(out)[:n] # type: ignore
def raf_write(ctx, data: Buffer, offset: int) -> int:
"""Encrypt and write data at offset, extending the file as needed.
Returns:
Number of bytes written.
Raises:
RuntimeError: If the write fails (errno name included).
"""
data = memoryview(data)
bytes_written = ffi.new("size_t*")
rc = _lib.aegis128x4_raf_write(
ctx.ptr, bytes_written, _ptr(data), data.nbytes, offset
)
if rc != 0:
_raise_errno("raf write")
return bytes_written[0]
def raf_truncate(ctx, size: int) -> None:
"""Resize the file to size bytes (zero-filled when growing).
Raises:
RuntimeError: If the truncate fails.
"""
rc = _lib.aegis128x4_raf_truncate(ctx.ptr, size)
if rc != 0:
_raise_errno("raf truncate")
def raf_size(ctx) -> int:
"""Return the logical plaintext file size."""
size = ffi.new("uint64_t*")
rc = _lib.aegis128x4_raf_get_size(ctx.ptr, size)
if rc != 0:
_raise_errno("raf get_size")
return size[0]
def raf_sync(ctx) -> None:
"""Flush writes to the backing store.
Raises:
RuntimeError: If the sync fails.
"""
rc = _lib.aegis128x4_raf_sync(ctx.ptr)
if rc != 0:
_raise_errno("raf sync")
def raf_close(ctx) -> None:
"""Close the context, syncing and zeroizing key material."""
_lib.aegis128x4_raf_close(ctx.ptr)
def raf_merkle_rebuild(ctx) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Raises:
RuntimeError: If Merkle is not enabled or rebuilding fails.
ValueError: If a chunk fails authentication.
"""
rc = _lib.aegis128x4_raf_merkle_rebuild(ctx.ptr)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf merkle_rebuild")
def raf_merkle_verify(ctx) -> 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.
Raises:
RuntimeError: If Merkle is not enabled or verification fails.
"""
corrupted = ffi.new("uint64_t*")
rc = _lib.aegis128x4_raf_merkle_verify(ctx.ptr, corrupted)
if rc == 0:
return None
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
return corrupted[0]
_raise_errno("raf merkle_verify")
def raf_merkle_commitment(ctx, hash_len: int) -> bytes:
"""Compute the Merkle root commitment hash.
Returns:
The commitment hash as bytes of length hash_len.
Raises:
RuntimeError: If Merkle is not enabled or the call fails.
"""
out = ffi.new(f"uint8_t[{hash_len}]")
rc = _lib.aegis128x4_raf_merkle_commitment(ctx.ptr, out, hash_len)
if rc != 0:
_raise_errno("raf merkle_commitment")
return bytes(ffi.buffer(out, hash_len))
__all__ = [ __all__ = [
# constants # constants
"NAME", "NAME",
@@ -877,7 +1102,9 @@ __all__ = [
"MACBYTES_LONG", "MACBYTES_LONG",
"ALIGNMENT", "ALIGNMENT",
"RATE", "RATE",
"RAF_ALG_ID",
# utility functions # utility functions
"random",
"random_key", "random_key",
"random_nonce", "random_nonce",
"nonce_increment", "nonce_increment",
@@ -895,4 +1122,18 @@ __all__ = [
"Encryptor", "Encryptor",
"Decryptor", "Decryptor",
"Mac", "Mac",
# random-access encrypted files (thin wrappers; see aeg.raf for the high-level API)
"new_raf_ctx",
"raf_scratch_size",
"raf_create",
"raf_open",
"raf_read",
"raf_write",
"raf_truncate",
"raf_size",
"raf_sync",
"raf_close",
"raf_merkle_rebuild",
"raf_merkle_verify",
"raf_merkle_commitment",
] ]
+241
View File
@@ -4,10 +4,12 @@
import errno import errno
import secrets import secrets
import sys
from typing import Literal from typing import Literal
from ._loader import ffi from ._loader import ffi
from ._loader import lib as _lib from ._loader import lib as _lib
from .random import Random
from .util import Buffer, new_aligned_struct, nonce_increment, wipe from .util import Buffer, new_aligned_struct, nonce_increment, wipe
NAME = "AEGIS-256" #: Algorithm display name NAME = "AEGIS-256" #: Algorithm display name
@@ -19,6 +21,15 @@ ALIGNMENT = 64 #: Required alignment for internal structures
RATE = 64 #: Byte chunk size in internal processing RATE = 64 #: Byte chunk size in internal processing
def random() -> Random:
"""Return a new AEGIS stream Random instance using this cipher module.
Seeded once from the OS, then deterministic: successive calls never
repeat an output block. See aeg.random.Random for the API.
"""
return Random(sys.modules[__name__])
def random_key() -> bytearray: def random_key() -> bytearray:
""" """
Generate a secret key using cryptographically secure random bytes. Generate a secret key using cryptographically secure random bytes.
@@ -868,6 +879,220 @@ def new_mac_state():
return new_aligned_struct("aegis256_mac_state", ALIGNMENT) return new_aligned_struct("aegis256_mac_state", ALIGNMENT)
RAF_ALG_ID = 4 #: Algorithm identifier in RAF file headers
def _raise_errno(operation: str) -> None:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"{operation} failed: {err_name}")
def new_raf_ctx():
"""Allocate and return a new aegis256_raf_ctx* with proper alignment."""
return new_aligned_struct("aegis256_raf_ctx", ALIGNMENT)
def raf_scratch_size(chunk_size: int) -> int:
"""Return the scratch buffer size required for the given chunk_size."""
return _lib.aegis256_raf_scratch_size(chunk_size)
def raf_create(key: Buffer, ctx, io, rng, config) -> None:
"""Create a new encrypted file, writing the header through io.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileExistsError: If the file exists and AEGIS_RAF_TRUNCATE was not set.
RuntimeError: If creation fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis256_raf_create(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
if ffi.errno == errno.EEXIST:
raise FileExistsError("file exists (use truncate to overwrite)")
_raise_errno("raf create")
def raf_open(key: Buffer, ctx, io, rng, config) -> None:
"""Open an existing encrypted file, verifying the header MAC.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileNotFoundError: If the file does not exist.
ValueError: If the header is invalid or authentication fails (wrong key).
RuntimeError: If opening fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis256_raf_open(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
err_num = ffi.errno
if err_num == errno.ENOENT:
raise FileNotFoundError("file not found")
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf open")
def raf_read(
ctx, length: int, offset: int, *, into: Buffer | None = None
) -> bytearray | memoryview:
"""Read and decrypt up to length bytes at offset.
Returns:
The decrypted bytes, trimmed to the number actually read
(fewer at EOF), as bytearray if into not provided,
memoryview of into otherwise.
Raises:
TypeError: If into is smaller than length.
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
if into is None:
out = bytearray(length)
else:
into = memoryview(into)
if into.nbytes < length:
raise TypeError("into length must be at least length")
out = into
bytes_read = ffi.new("size_t*")
rc = _lib.aegis256_raf_read(
ctx.ptr, ffi.from_buffer(out), bytes_read, length, offset
)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf read")
n = bytes_read[0]
return out[:n] if into is None else memoryview(out)[:n] # type: ignore
def raf_write(ctx, data: Buffer, offset: int) -> int:
"""Encrypt and write data at offset, extending the file as needed.
Returns:
Number of bytes written.
Raises:
RuntimeError: If the write fails (errno name included).
"""
data = memoryview(data)
bytes_written = ffi.new("size_t*")
rc = _lib.aegis256_raf_write(
ctx.ptr, bytes_written, _ptr(data), data.nbytes, offset
)
if rc != 0:
_raise_errno("raf write")
return bytes_written[0]
def raf_truncate(ctx, size: int) -> None:
"""Resize the file to size bytes (zero-filled when growing).
Raises:
RuntimeError: If the truncate fails.
"""
rc = _lib.aegis256_raf_truncate(ctx.ptr, size)
if rc != 0:
_raise_errno("raf truncate")
def raf_size(ctx) -> int:
"""Return the logical plaintext file size."""
size = ffi.new("uint64_t*")
rc = _lib.aegis256_raf_get_size(ctx.ptr, size)
if rc != 0:
_raise_errno("raf get_size")
return size[0]
def raf_sync(ctx) -> None:
"""Flush writes to the backing store.
Raises:
RuntimeError: If the sync fails.
"""
rc = _lib.aegis256_raf_sync(ctx.ptr)
if rc != 0:
_raise_errno("raf sync")
def raf_close(ctx) -> None:
"""Close the context, syncing and zeroizing key material."""
_lib.aegis256_raf_close(ctx.ptr)
def raf_merkle_rebuild(ctx) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Raises:
RuntimeError: If Merkle is not enabled or rebuilding fails.
ValueError: If a chunk fails authentication.
"""
rc = _lib.aegis256_raf_merkle_rebuild(ctx.ptr)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf merkle_rebuild")
def raf_merkle_verify(ctx) -> 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.
Raises:
RuntimeError: If Merkle is not enabled or verification fails.
"""
corrupted = ffi.new("uint64_t*")
rc = _lib.aegis256_raf_merkle_verify(ctx.ptr, corrupted)
if rc == 0:
return None
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
return corrupted[0]
_raise_errno("raf merkle_verify")
def raf_merkle_commitment(ctx, hash_len: int) -> bytes:
"""Compute the Merkle root commitment hash.
Returns:
The commitment hash as bytes of length hash_len.
Raises:
RuntimeError: If Merkle is not enabled or the call fails.
"""
out = ffi.new(f"uint8_t[{hash_len}]")
rc = _lib.aegis256_raf_merkle_commitment(ctx.ptr, out, hash_len)
if rc != 0:
_raise_errno("raf merkle_commitment")
return bytes(ffi.buffer(out, hash_len))
__all__ = [ __all__ = [
# constants # constants
"NAME", "NAME",
@@ -877,7 +1102,9 @@ __all__ = [
"MACBYTES_LONG", "MACBYTES_LONG",
"ALIGNMENT", "ALIGNMENT",
"RATE", "RATE",
"RAF_ALG_ID",
# utility functions # utility functions
"random",
"random_key", "random_key",
"random_nonce", "random_nonce",
"nonce_increment", "nonce_increment",
@@ -895,4 +1122,18 @@ __all__ = [
"Encryptor", "Encryptor",
"Decryptor", "Decryptor",
"Mac", "Mac",
# random-access encrypted files (thin wrappers; see aeg.raf for the high-level API)
"new_raf_ctx",
"raf_scratch_size",
"raf_create",
"raf_open",
"raf_read",
"raf_write",
"raf_truncate",
"raf_size",
"raf_sync",
"raf_close",
"raf_merkle_rebuild",
"raf_merkle_verify",
"raf_merkle_commitment",
] ]
+241
View File
@@ -4,10 +4,12 @@
import errno import errno
import secrets import secrets
import sys
from typing import Literal from typing import Literal
from ._loader import ffi from ._loader import ffi
from ._loader import lib as _lib from ._loader import lib as _lib
from .random import Random
from .util import Buffer, new_aligned_struct, nonce_increment, wipe from .util import Buffer, new_aligned_struct, nonce_increment, wipe
NAME = "AEGIS-256X2" #: Algorithm display name NAME = "AEGIS-256X2" #: Algorithm display name
@@ -19,6 +21,15 @@ ALIGNMENT = 64 #: Required alignment for internal structures
RATE = 64 #: Byte chunk size in internal processing RATE = 64 #: Byte chunk size in internal processing
def random() -> Random:
"""Return a new AEGIS stream Random instance using this cipher module.
Seeded once from the OS, then deterministic: successive calls never
repeat an output block. See aeg.random.Random for the API.
"""
return Random(sys.modules[__name__])
def random_key() -> bytearray: def random_key() -> bytearray:
""" """
Generate a secret key using cryptographically secure random bytes. Generate a secret key using cryptographically secure random bytes.
@@ -868,6 +879,220 @@ def new_mac_state():
return new_aligned_struct("aegis256x2_mac_state", ALIGNMENT) return new_aligned_struct("aegis256x2_mac_state", ALIGNMENT)
RAF_ALG_ID = 5 #: Algorithm identifier in RAF file headers
def _raise_errno(operation: str) -> None:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"{operation} failed: {err_name}")
def new_raf_ctx():
"""Allocate and return a new aegis256x2_raf_ctx* with proper alignment."""
return new_aligned_struct("aegis256x2_raf_ctx", ALIGNMENT)
def raf_scratch_size(chunk_size: int) -> int:
"""Return the scratch buffer size required for the given chunk_size."""
return _lib.aegis256x2_raf_scratch_size(chunk_size)
def raf_create(key: Buffer, ctx, io, rng, config) -> None:
"""Create a new encrypted file, writing the header through io.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileExistsError: If the file exists and AEGIS_RAF_TRUNCATE was not set.
RuntimeError: If creation fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis256x2_raf_create(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
if ffi.errno == errno.EEXIST:
raise FileExistsError("file exists (use truncate to overwrite)")
_raise_errno("raf create")
def raf_open(key: Buffer, ctx, io, rng, config) -> None:
"""Open an existing encrypted file, verifying the header MAC.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileNotFoundError: If the file does not exist.
ValueError: If the header is invalid or authentication fails (wrong key).
RuntimeError: If opening fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis256x2_raf_open(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
err_num = ffi.errno
if err_num == errno.ENOENT:
raise FileNotFoundError("file not found")
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf open")
def raf_read(
ctx, length: int, offset: int, *, into: Buffer | None = None
) -> bytearray | memoryview:
"""Read and decrypt up to length bytes at offset.
Returns:
The decrypted bytes, trimmed to the number actually read
(fewer at EOF), as bytearray if into not provided,
memoryview of into otherwise.
Raises:
TypeError: If into is smaller than length.
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
if into is None:
out = bytearray(length)
else:
into = memoryview(into)
if into.nbytes < length:
raise TypeError("into length must be at least length")
out = into
bytes_read = ffi.new("size_t*")
rc = _lib.aegis256x2_raf_read(
ctx.ptr, ffi.from_buffer(out), bytes_read, length, offset
)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf read")
n = bytes_read[0]
return out[:n] if into is None else memoryview(out)[:n] # type: ignore
def raf_write(ctx, data: Buffer, offset: int) -> int:
"""Encrypt and write data at offset, extending the file as needed.
Returns:
Number of bytes written.
Raises:
RuntimeError: If the write fails (errno name included).
"""
data = memoryview(data)
bytes_written = ffi.new("size_t*")
rc = _lib.aegis256x2_raf_write(
ctx.ptr, bytes_written, _ptr(data), data.nbytes, offset
)
if rc != 0:
_raise_errno("raf write")
return bytes_written[0]
def raf_truncate(ctx, size: int) -> None:
"""Resize the file to size bytes (zero-filled when growing).
Raises:
RuntimeError: If the truncate fails.
"""
rc = _lib.aegis256x2_raf_truncate(ctx.ptr, size)
if rc != 0:
_raise_errno("raf truncate")
def raf_size(ctx) -> int:
"""Return the logical plaintext file size."""
size = ffi.new("uint64_t*")
rc = _lib.aegis256x2_raf_get_size(ctx.ptr, size)
if rc != 0:
_raise_errno("raf get_size")
return size[0]
def raf_sync(ctx) -> None:
"""Flush writes to the backing store.
Raises:
RuntimeError: If the sync fails.
"""
rc = _lib.aegis256x2_raf_sync(ctx.ptr)
if rc != 0:
_raise_errno("raf sync")
def raf_close(ctx) -> None:
"""Close the context, syncing and zeroizing key material."""
_lib.aegis256x2_raf_close(ctx.ptr)
def raf_merkle_rebuild(ctx) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Raises:
RuntimeError: If Merkle is not enabled or rebuilding fails.
ValueError: If a chunk fails authentication.
"""
rc = _lib.aegis256x2_raf_merkle_rebuild(ctx.ptr)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf merkle_rebuild")
def raf_merkle_verify(ctx) -> 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.
Raises:
RuntimeError: If Merkle is not enabled or verification fails.
"""
corrupted = ffi.new("uint64_t*")
rc = _lib.aegis256x2_raf_merkle_verify(ctx.ptr, corrupted)
if rc == 0:
return None
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
return corrupted[0]
_raise_errno("raf merkle_verify")
def raf_merkle_commitment(ctx, hash_len: int) -> bytes:
"""Compute the Merkle root commitment hash.
Returns:
The commitment hash as bytes of length hash_len.
Raises:
RuntimeError: If Merkle is not enabled or the call fails.
"""
out = ffi.new(f"uint8_t[{hash_len}]")
rc = _lib.aegis256x2_raf_merkle_commitment(ctx.ptr, out, hash_len)
if rc != 0:
_raise_errno("raf merkle_commitment")
return bytes(ffi.buffer(out, hash_len))
__all__ = [ __all__ = [
# constants # constants
"NAME", "NAME",
@@ -877,7 +1102,9 @@ __all__ = [
"MACBYTES_LONG", "MACBYTES_LONG",
"ALIGNMENT", "ALIGNMENT",
"RATE", "RATE",
"RAF_ALG_ID",
# utility functions # utility functions
"random",
"random_key", "random_key",
"random_nonce", "random_nonce",
"nonce_increment", "nonce_increment",
@@ -895,4 +1122,18 @@ __all__ = [
"Encryptor", "Encryptor",
"Decryptor", "Decryptor",
"Mac", "Mac",
# random-access encrypted files (thin wrappers; see aeg.raf for the high-level API)
"new_raf_ctx",
"raf_scratch_size",
"raf_create",
"raf_open",
"raf_read",
"raf_write",
"raf_truncate",
"raf_size",
"raf_sync",
"raf_close",
"raf_merkle_rebuild",
"raf_merkle_verify",
"raf_merkle_commitment",
] ]
+241
View File
@@ -4,10 +4,12 @@
import errno import errno
import secrets import secrets
import sys
from typing import Literal from typing import Literal
from ._loader import ffi from ._loader import ffi
from ._loader import lib as _lib from ._loader import lib as _lib
from .random import Random
from .util import Buffer, new_aligned_struct, nonce_increment, wipe from .util import Buffer, new_aligned_struct, nonce_increment, wipe
NAME = "AEGIS-256X4" #: Algorithm display name NAME = "AEGIS-256X4" #: Algorithm display name
@@ -19,6 +21,15 @@ ALIGNMENT = 64 #: Required alignment for internal structures
RATE = 64 #: Byte chunk size in internal processing RATE = 64 #: Byte chunk size in internal processing
def random() -> Random:
"""Return a new AEGIS stream Random instance using this cipher module.
Seeded once from the OS, then deterministic: successive calls never
repeat an output block. See aeg.random.Random for the API.
"""
return Random(sys.modules[__name__])
def random_key() -> bytearray: def random_key() -> bytearray:
""" """
Generate a secret key using cryptographically secure random bytes. Generate a secret key using cryptographically secure random bytes.
@@ -868,6 +879,220 @@ def new_mac_state():
return new_aligned_struct("aegis256x4_mac_state", ALIGNMENT) return new_aligned_struct("aegis256x4_mac_state", ALIGNMENT)
RAF_ALG_ID = 6 #: Algorithm identifier in RAF file headers
def _raise_errno(operation: str) -> None:
err_num = ffi.errno
err_name = errno.errorcode.get(err_num, f"errno_{err_num}")
raise RuntimeError(f"{operation} failed: {err_name}")
def new_raf_ctx():
"""Allocate and return a new aegis256x4_raf_ctx* with proper alignment."""
return new_aligned_struct("aegis256x4_raf_ctx", ALIGNMENT)
def raf_scratch_size(chunk_size: int) -> int:
"""Return the scratch buffer size required for the given chunk_size."""
return _lib.aegis256x4_raf_scratch_size(chunk_size)
def raf_create(key: Buffer, ctx, io, rng, config) -> None:
"""Create a new encrypted file, writing the header through io.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileExistsError: If the file exists and AEGIS_RAF_TRUNCATE was not set.
RuntimeError: If creation fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis256x4_raf_create(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
if ffi.errno == errno.EEXIST:
raise FileExistsError("file exists (use truncate to overwrite)")
_raise_errno("raf create")
def raf_open(key: Buffer, ctx, io, rng, config) -> None:
"""Open an existing encrypted file, verifying the header MAC.
Args:
key: Master key (KEYBYTES bytes).
ctx: Context from new_raf_ctx().
io: aegis_raf_io* cdata (see aeg.raf).
rng: aegis_raf_rng* cdata (see aeg.random).
config: aegis_raf_config* cdata (see aeg.raf).
Raises:
TypeError: If key length is invalid.
FileNotFoundError: If the file does not exist.
ValueError: If the header is invalid or authentication fails (wrong key).
RuntimeError: If opening fails.
"""
key = memoryview(key)
if key.nbytes != KEYBYTES:
raise TypeError(f"key length must be {KEYBYTES}")
rc = _lib.aegis256x4_raf_open(ctx.ptr, io, rng, config, _ptr(key))
if rc != 0:
err_num = ffi.errno
if err_num == errno.ENOENT:
raise FileNotFoundError("file not found")
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf open")
def raf_read(
ctx, length: int, offset: int, *, into: Buffer | None = None
) -> bytearray | memoryview:
"""Read and decrypt up to length bytes at offset.
Returns:
The decrypted bytes, trimmed to the number actually read
(fewer at EOF), as bytearray if into not provided,
memoryview of into otherwise.
Raises:
TypeError: If into is smaller than length.
ValueError: If authentication fails (corruption or tampering).
RuntimeError: If the read fails.
"""
if into is None:
out = bytearray(length)
else:
into = memoryview(into)
if into.nbytes < length:
raise TypeError("into length must be at least length")
out = into
bytes_read = ffi.new("size_t*")
rc = _lib.aegis256x4_raf_read(
ctx.ptr, ffi.from_buffer(out), bytes_read, length, offset
)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf read")
n = bytes_read[0]
return out[:n] if into is None else memoryview(out)[:n] # type: ignore
def raf_write(ctx, data: Buffer, offset: int) -> int:
"""Encrypt and write data at offset, extending the file as needed.
Returns:
Number of bytes written.
Raises:
RuntimeError: If the write fails (errno name included).
"""
data = memoryview(data)
bytes_written = ffi.new("size_t*")
rc = _lib.aegis256x4_raf_write(
ctx.ptr, bytes_written, _ptr(data), data.nbytes, offset
)
if rc != 0:
_raise_errno("raf write")
return bytes_written[0]
def raf_truncate(ctx, size: int) -> None:
"""Resize the file to size bytes (zero-filled when growing).
Raises:
RuntimeError: If the truncate fails.
"""
rc = _lib.aegis256x4_raf_truncate(ctx.ptr, size)
if rc != 0:
_raise_errno("raf truncate")
def raf_size(ctx) -> int:
"""Return the logical plaintext file size."""
size = ffi.new("uint64_t*")
rc = _lib.aegis256x4_raf_get_size(ctx.ptr, size)
if rc != 0:
_raise_errno("raf get_size")
return size[0]
def raf_sync(ctx) -> None:
"""Flush writes to the backing store.
Raises:
RuntimeError: If the sync fails.
"""
rc = _lib.aegis256x4_raf_sync(ctx.ptr)
if rc != 0:
_raise_errno("raf sync")
def raf_close(ctx) -> None:
"""Close the context, syncing and zeroizing key material."""
_lib.aegis256x4_raf_close(ctx.ptr)
def raf_merkle_rebuild(ctx) -> None:
"""Rebuild the Merkle tree by reading and re-hashing every chunk.
Raises:
RuntimeError: If Merkle is not enabled or rebuilding fails.
ValueError: If a chunk fails authentication.
"""
rc = _lib.aegis256x4_raf_merkle_rebuild(ctx.ptr)
if rc != 0:
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
raise ValueError("authentication failed")
_raise_errno("raf merkle_rebuild")
def raf_merkle_verify(ctx) -> 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.
Raises:
RuntimeError: If Merkle is not enabled or verification fails.
"""
corrupted = ffi.new("uint64_t*")
rc = _lib.aegis256x4_raf_merkle_verify(ctx.ptr, corrupted)
if rc == 0:
return None
err_num = ffi.errno
if err_num in (errno.EINVAL, getattr(errno, "EBADMSG", -1)):
return corrupted[0]
_raise_errno("raf merkle_verify")
def raf_merkle_commitment(ctx, hash_len: int) -> bytes:
"""Compute the Merkle root commitment hash.
Returns:
The commitment hash as bytes of length hash_len.
Raises:
RuntimeError: If Merkle is not enabled or the call fails.
"""
out = ffi.new(f"uint8_t[{hash_len}]")
rc = _lib.aegis256x4_raf_merkle_commitment(ctx.ptr, out, hash_len)
if rc != 0:
_raise_errno("raf merkle_commitment")
return bytes(ffi.buffer(out, hash_len))
__all__ = [ __all__ = [
# constants # constants
"NAME", "NAME",
@@ -877,7 +1102,9 @@ __all__ = [
"MACBYTES_LONG", "MACBYTES_LONG",
"ALIGNMENT", "ALIGNMENT",
"RATE", "RATE",
"RAF_ALG_ID",
# utility functions # utility functions
"random",
"random_key", "random_key",
"random_nonce", "random_nonce",
"nonce_increment", "nonce_increment",
@@ -895,4 +1122,18 @@ __all__ = [
"Encryptor", "Encryptor",
"Decryptor", "Decryptor",
"Mac", "Mac",
# random-access encrypted files (thin wrappers; see aeg.raf for the high-level API)
"new_raf_ctx",
"raf_scratch_size",
"raf_create",
"raf_open",
"raf_read",
"raf_write",
"raf_truncate",
"raf_size",
"raf_sync",
"raf_close",
"raf_merkle_rebuild",
"raf_merkle_verify",
"raf_merkle_commitment",
] ]
+229
View File
@@ -475,3 +475,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);
+917
View File
@@ -0,0 +1,917 @@
"""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 either a
path 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 os
import threading
from typing import NamedTuple, Protocol, 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",
"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) -> FileStorage:
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) -> BytesIOStorage:
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
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) -> "Raf":
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")
+113
View File
@@ -0,0 +1,113 @@
"""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 create one is the cipher module's own random()
helper, e.g. ``aeg.cipher("AEGIS-128X2").random()``.
"""
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)
File diff suppressed because it is too large. Load diff
+358
View File
@@ -0,0 +1,358 @@
import pytest
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
from aeg import 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_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
+23 -3
View File
@@ -88,6 +88,7 @@ def generate_cdef(include_dir: pathlib.Path) -> str:
"aegis256.h", "aegis256.h",
"aegis256x2.h", "aegis256x2.h",
"aegis256x4.h", "aegis256x4.h",
"aegis_raf.h",
] ]
for header_name in headers: for header_name in headers:
@@ -105,9 +106,9 @@ def generate_cdef(include_dir: pathlib.Path) -> str:
def extract_constants( def extract_constants(
common_h_path: pathlib.Path, header_path: pathlib.Path common_h_path: pathlib.Path, header_path: pathlib.Path, raf_h_path: pathlib.Path
) -> Dict[str, int]: ) -> Dict[str, int]:
"""Extract constants from common.h (ALIGNMENT, RATE) and main header (KEYBYTES, NPUBBYTES, ABYTES_*).""" """Extract constants from common.h (ALIGNMENT, RATE), main header (KEYBYTES, NPUBBYTES, ABYTES_*), and aegis_raf.h (RAF_ALG_ID)."""
constants = {} constants = {}
# Extract from common.h # Extract from common.h
@@ -136,6 +137,14 @@ def extract_constants(
raise ValueError(f"Could not extract {const_name} from {header_path}") raise ValueError(f"Could not extract {const_name} from {header_path}")
constants[const_name] = int(match.group(1)) 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 return constants
@@ -155,6 +164,7 @@ def extract_all_constants(
for variant in variants: for variant in variants:
common_h = libaegis_src_dir / variant / f"{variant}_common.h" common_h = libaegis_src_dir / variant / f"{variant}_common.h"
header_h = include_dir / f"{variant}.h" header_h = include_dir / f"{variant}.h"
raf_h = include_dir / "aegis_raf.h"
if not common_h.exists(): if not common_h.exists():
print(f"Warning: {common_h} not found, skipping {variant}", file=sys.stderr) print(f"Warning: {common_h} not found, skipping {variant}", file=sys.stderr)
@@ -165,7 +175,7 @@ def extract_all_constants(
continue continue
try: try:
constants[variant] = extract_constants(common_h, header_h) constants[variant] = extract_constants(common_h, header_h, raf_h)
except Exception as e: except Exception as e:
print(f"Error extracting constants from {variant}: {e}", file=sys.stderr) print(f"Error extracting constants from {variant}: {e}", file=sys.stderr)
@@ -215,6 +225,11 @@ def generate_variant(template_src: str, variant: str, constants: Dict[str, int])
f"MACBYTES_LONG = {constants['ABYTES_MAX']}", f"MACBYTES_LONG = {constants['ABYTES_MAX']}",
s, s,
) )
s = re.sub(
r"RAF_ALG_ID = \d+",
f"RAF_ALG_ID = {constants['RAF_ALG_ID']}",
s,
)
return s return s
@@ -262,6 +277,11 @@ def generate_python_modules(
f"MACBYTES_LONG = {const_dict['ABYTES_MAX']}", f"MACBYTES_LONG = {const_dict['ABYTES_MAX']}",
new_content, new_content,
) )
new_content = re.sub(
r"RAF_ALG_ID = \d+",
f"RAF_ALG_ID = {const_dict['RAF_ALG_ID']}",
new_content,
)
else: else:
new_content = generate_variant(template_src, variant, const_dict) new_content = generate_variant(template_src, variant, const_dict)