From faa24cf22e68b7cd46a00376c95ef2f5e3ae3869 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 4 Oct 2026 05:43:58 +0000 Subject: [PATCH] Add aeg.securebuf.SecureBuffer: locked, wipe-on-close memory mmap-backed buffer locked against swapping (mlock/VirtualLock) and excluded from core dumps (MADV_DONTDUMP) where available, wiped on close. Not used for return values; intended as an 'into' target (and key input) to keep sensitive material out of the regular heap. Adapted from quantumpipe's securebuf module. --- src/aeg/securebuf.py | 115 ++++++++++++++++++++++++++++++++++++++++ tests/test_securebuf.py | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/aeg/securebuf.py create mode 100644 tests/test_securebuf.py diff --git a/src/aeg/securebuf.py b/src/aeg/securebuf.py new file mode 100644 index 0000000..7b44c87 --- /dev/null +++ b/src/aeg/securebuf.py @@ -0,0 +1,115 @@ +"""SecureBuffer: locked, core-dump-excluded memory for sensitive data. + +Backed by an anonymous mmap that is locked against swapping (mlock / +VirtualLock) and excluded from core dumps (MADV_DONTDUMP) where available. +The contents are wiped on close(). + +SecureBuffer is not used for return values; allocate one yourself and pass +it as an ``into`` target to keep plaintext/keys out of the regular heap: + + with SecureBuffer(4096) as buf: + ciph.decrypt(key, nonce, ct, mac, into=buf) + +Adapted from quantumpipe's securebuf module. +""" + +import ctypes +import mmap +import os +from contextlib import suppress + +from .util import wipe + +__all__ = ["SecureBuffer"] + + +class SecureBuffer: + """A fixed-size buffer in locked, non-dumped memory, wiped on close.""" + + def __init__(self, size: int): + if size <= 0: + raise ValueError(f"size must be positive, got {size}") + self._mmap = mmap.mmap(-1, size, access=mmap.ACCESS_WRITE) + try: + self._lock() + self._dontdump() + except BaseException: + self._mmap.close() + raise + + @property + def closed(self) -> bool: + """True if the buffer has been closed.""" + return self._mmap.closed + + def _addr(self) -> int: + return ctypes.addressof(ctypes.c_char.from_buffer(self._mmap)) + + def _lock(self) -> None: + addr, size = self._addr(), len(self._mmap) + if os.name == "nt": + api = ctypes.WinDLL("kernel32", use_last_error=True) + api.VirtualLock.argtypes = (ctypes.c_void_p, ctypes.c_size_t) + api.VirtualLock.restype = ctypes.c_bool + if not api.VirtualLock(addr, size): + raise ctypes.WinError(ctypes.get_last_error()) + else: + libc = ctypes.CDLL(None, use_errno=True) + libc.mlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t) + libc.mlock.restype = ctypes.c_int + if libc.mlock(addr, size): + err = ctypes.get_errno() + raise OSError(err, os.strerror(err)) + + def _unlock(self) -> None: + addr, size = self._addr(), len(self._mmap) + if os.name == "nt": + api = ctypes.WinDLL("kernel32", use_last_error=True) + api.VirtualUnlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t) + api.VirtualUnlock.restype = ctypes.c_bool + if not api.VirtualUnlock(addr, size): + raise ctypes.WinError(ctypes.get_last_error()) + else: + libc = ctypes.CDLL(None, use_errno=True) + libc.munlock.argtypes = (ctypes.c_void_p, ctypes.c_size_t) + libc.munlock.restype = ctypes.c_int + if libc.munlock(addr, size): + err = ctypes.get_errno() + raise OSError(err, os.strerror(err)) + + def _dontdump(self) -> None: + if hasattr(self._mmap, "madvise") and hasattr(mmap, "MADV_DONTDUMP"): + self._mmap.madvise(mmap.MADV_DONTDUMP) + + def close(self) -> None: + """Wipe the contents, unlock and release the memory. Idempotent.""" + if self.closed: + return + wipe(self._mmap) + self._unlock() + self._mmap.close() + + def __buffer__(self, flags: int) -> memoryview: + return memoryview(self._mmap) + + def __getitem__(self, key): + return memoryview(self._mmap)[key] + + def __setitem__(self, key, value): + memoryview(self._mmap)[key] = value + + def __bytes__(self): + raise TypeError("SecureBuffer cannot be converted to bytes") + + def __len__(self) -> int: + return len(self._mmap) + + def __enter__(self) -> "SecureBuffer": + return self + + def __exit__(self, *args) -> None: + self.close() + + def __del__(self): + with suppress(Exception): + self.close() diff --git a/tests/test_securebuf.py b/tests/test_securebuf.py new file mode 100644 index 0000000..e4737da --- /dev/null +++ b/tests/test_securebuf.py @@ -0,0 +1,106 @@ +"""Tests for aeg.securebuf.SecureBuffer. + +CPython-only assumptions: reference counting frees memoryviews as soon as +they are del'd; live exported views would make mmap.close() fail with +BufferError. +""" + +import pytest + +from aeg import aegis128l +from aeg.securebuf import SecureBuffer +from aeg.util import wipe + + +def test_buffer_protocol_read_write(): + with SecureBuffer(32) as sb: + assert len(sb) == 32 + mv = memoryview(sb) + assert len(mv) == 32 + assert not mv.readonly + mv[:4] = b"abcd" + assert mv[:4] == b"abcd" + del mv + assert sb.closed + + +def test_not_convertible_to_bytes(): + with SecureBuffer(16) as sb, pytest.raises(TypeError, match="cannot be converted"): + bytes(sb) + + +def test_invalid_size(): + for size in (0, -1): + with pytest.raises(ValueError): + SecureBuffer(size) + + +def test_close_is_idempotent_and_blocks_access(): + sb = SecureBuffer(16) + sb.close() + assert sb.closed + with pytest.raises(ValueError): + memoryview(sb) + sb.close() + assert sb.closed + + +def test_context_manager_closes_on_exception(): + with pytest.raises(RuntimeError), SecureBuffer(16) as sb: + raise RuntimeError("boom") + assert sb.closed + + +def test_del_closes(): + import gc + + sb = SecureBuffer(16) + ref = sb._mmap + del sb + gc.collect() + assert ref.closed + + +def test_getitem_returns_memoryview(): + with SecureBuffer(16) as sb: + sb[:4] = b"abcd" + view = sb[2:8] + assert isinstance(view, memoryview) + assert view.tobytes() == b"cd\x00\x00\x00\x00" + del view + + +def test_wipe_fills_ff(): + sb = SecureBuffer(16) + sb[:] = b"A" * 16 + wipe(sb) + mv = memoryview(sb) + assert mv.tobytes() == b"\xff" * 16 + del mv + sb.close() + + +def test_roundtrip_as_key_and_into_target(): + """SecureBuffer works as key input and as an `into` target.""" + c = aegis128l + with SecureBuffer(c.KEYBYTES) as key, SecureBuffer(c.NONCEBYTES) as nonce: + key[:] = bytes(range(c.KEYBYTES)) + nonce[:] = bytes(c.NONCEBYTES) + msg = b"hello secure world" + ct, mac = c.encrypt_detached(key, nonce, msg) + with SecureBuffer(len(msg)) as pt: + out = c.decrypt_detached(key, nonce, ct, mac, into=pt) + assert len(out) == len(msg) + view = pt[:] + assert view.tobytes() == msg + del out, view + + +def test_stream_into(): + c = aegis128l + key, nonce = c.random_key(), c.random_nonce() + with SecureBuffer(64) as buf: + c.stream(key, nonce, into=buf) + view = buf[:] + assert view.tobytes() == bytes(c.stream(key, nonce, 64)) + del view