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.
1141 lines
37 KiB
Python
1141 lines
37 KiB
Python
"""Fuzz tests for RAF and Merkle tree features.
|
|
|
|
Adapted from pyaegis (github.com/jedisct1/pyaegis) tests by Frank Denis.
|
|
|
|
Exercises random operation sequences, edge cases, and consistency invariants
|
|
to verify reliability of the encrypted random-access file and Merkle tree.
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from aeg import aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4
|
|
from aeg import raf
|
|
from aeg.raf import BytesIOStorage, FileStorage
|
|
|
|
CIPHERS = [aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4]
|
|
|
|
# Subset used for heavier fuzz loops, as in pyaegis
|
|
FUZZ_CIPHERS = [aegis128l, aegis256]
|
|
|
|
|
|
def cipher_id(c):
|
|
return c.NAME
|
|
|
|
|
|
class RefFile:
|
|
"""Reference in-memory plaintext file for oracle comparison."""
|
|
|
|
def __init__(self):
|
|
self._data = bytearray()
|
|
self._pos = 0
|
|
|
|
@property
|
|
def size(self):
|
|
return len(self._data)
|
|
|
|
def write(self, data, offset=None):
|
|
if offset is None:
|
|
offset = self._pos
|
|
end = offset + len(data)
|
|
if end > len(self._data):
|
|
self._data.extend(b"\x00" * (end - len(self._data)))
|
|
self._data[offset : offset + len(data)] = data
|
|
self._pos = end
|
|
return len(data)
|
|
|
|
def pwrite(self, data, offset):
|
|
end = offset + len(data)
|
|
if end > len(self._data):
|
|
self._data.extend(b"\x00" * (end - len(self._data)))
|
|
self._data[offset : offset + len(data)] = data
|
|
return len(data)
|
|
|
|
def read(self, size=-1, offset=None):
|
|
if offset is None:
|
|
offset = self._pos
|
|
if size < 0:
|
|
size = max(0, len(self._data) - offset)
|
|
result = bytes(self._data[offset : offset + size])
|
|
self._pos = offset + len(result)
|
|
return result
|
|
|
|
def pread(self, size, offset):
|
|
return bytes(self._data[offset : offset + size])
|
|
|
|
def truncate(self, size=None):
|
|
if size is None:
|
|
size = self._pos
|
|
if size < len(self._data):
|
|
self._data = self._data[:size]
|
|
else:
|
|
self._data.extend(b"\x00" * (size - len(self._data)))
|
|
return size
|
|
|
|
def seek(self, offset, whence=0):
|
|
if whence == 0:
|
|
self._pos = offset
|
|
elif whence == 1:
|
|
self._pos += offset
|
|
elif whence == 2:
|
|
self._pos = len(self._data) + offset
|
|
self._pos = max(0, self._pos)
|
|
return self._pos
|
|
|
|
def tell(self):
|
|
return self._pos
|
|
|
|
|
|
class Blake2bMerkleHasher:
|
|
"""Custom Merkle hasher using BLAKE2b for testing."""
|
|
|
|
hash_len = 32
|
|
|
|
def hash_leaf(self, chunk: bytes, chunk_len: int, chunk_idx: int) -> bytes:
|
|
h = hashlib.blake2b(digest_size=32)
|
|
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.blake2b(digest_size=32)
|
|
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.blake2b(digest_size=32)
|
|
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.blake2b(digest_size=32)
|
|
h.update(b"\x03")
|
|
h.update(structural_root)
|
|
h.update(ctx)
|
|
h.update(file_size.to_bytes(8, "little"))
|
|
return h.digest()
|
|
|
|
|
|
class TestFuzzRAFRandomOps:
|
|
"""Random operation sequences compared against a reference oracle."""
|
|
|
|
@pytest.mark.parametrize("seed", range(20))
|
|
@pytest.mark.parametrize("c", FUZZ_CIPHERS, ids=cipher_id)
|
|
def test_random_ops_no_merkle(self, seed, c):
|
|
"""Random write/read/seek/truncate ops match reference."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = c.random_key()
|
|
chunk_size = rng.choice([1024, 2048, 4096])
|
|
ref = RefFile()
|
|
|
|
with raf.create(storage, key, cipher=c, chunk_size=chunk_size) as f:
|
|
for _ in range(50):
|
|
op = rng.choice(
|
|
[
|
|
"write",
|
|
"write",
|
|
"pwrite",
|
|
"read",
|
|
"pread",
|
|
"seek",
|
|
"truncate",
|
|
"read_all",
|
|
"size",
|
|
]
|
|
)
|
|
self._do_op(rng, f, ref, op)
|
|
|
|
@pytest.mark.parametrize("seed", range(20))
|
|
def test_random_ops_with_merkle(self, seed):
|
|
"""Random ops with merkle=True; root stays consistent."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
chunk_size = rng.choice([1024, 2048])
|
|
ref = RefFile()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=256,
|
|
) as f:
|
|
for _ in range(40):
|
|
op = rng.choice(
|
|
[
|
|
"write",
|
|
"write",
|
|
"pwrite",
|
|
"read",
|
|
"pread",
|
|
"seek",
|
|
"truncate",
|
|
"read_all",
|
|
"size",
|
|
]
|
|
)
|
|
self._do_op(rng, f, ref, op, max_data=chunk_size * 2)
|
|
|
|
root = f.root_hash
|
|
assert root is not None and len(root) == 32
|
|
|
|
# Reopen and verify
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=256
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
# Data still matches reference
|
|
assert f.pread(ref.size, 0) == bytes(ref._data)
|
|
|
|
def _do_op(self, rng, f, ref, op, max_data=4096):
|
|
if op == "write":
|
|
data = os.urandom(rng.randint(0, max_data))
|
|
# Sometimes write at current pos, sometimes at explicit offset
|
|
if rng.random() < 0.3 and ref.size > 0:
|
|
offset = rng.randint(0, ref.size)
|
|
f.write(data, offset=offset)
|
|
ref.write(data, offset=offset)
|
|
else:
|
|
f.write(data)
|
|
ref.write(data)
|
|
|
|
elif op == "pwrite":
|
|
data = os.urandom(rng.randint(0, max_data))
|
|
offset = rng.randint(0, max(1, ref.size))
|
|
f.pwrite(data, offset)
|
|
ref.pwrite(data, offset)
|
|
|
|
elif op == "read":
|
|
size = rng.randint(0, max_data)
|
|
got = f.read(size)
|
|
expected = ref.read(size)
|
|
assert got == expected, (
|
|
f"read({size}): got {len(got)} bytes, expected {len(expected)}"
|
|
)
|
|
|
|
elif op == "pread":
|
|
if ref.size > 0:
|
|
offset = rng.randint(0, ref.size - 1)
|
|
size = rng.randint(0, ref.size - offset + 100)
|
|
else:
|
|
offset = 0
|
|
size = rng.randint(0, 100)
|
|
got = f.pread(size, offset)
|
|
expected = ref.pread(size, offset)
|
|
assert got == expected
|
|
|
|
elif op == "read_all":
|
|
offset = rng.randint(0, max(1, ref.size))
|
|
f.seek(offset)
|
|
ref.seek(offset)
|
|
got = f.read(-1)
|
|
expected = ref.read(-1)
|
|
assert got == expected
|
|
|
|
elif op == "seek":
|
|
whence = rng.choice([0, 1, 2])
|
|
if whence == 0:
|
|
offset = rng.randint(0, max(1, ref.size + 100))
|
|
elif whence == 1:
|
|
# Avoid negative resulting position
|
|
offset = rng.randint(-ref.tell(), ref.size + 50)
|
|
else:
|
|
offset = rng.randint(-ref.size, 0) if ref.size > 0 else 0
|
|
new_pos = f.seek(offset, whence)
|
|
ref_pos = ref.seek(offset, whence)
|
|
assert new_pos == ref_pos, (
|
|
f"seek({offset}, {whence}): {new_pos} != {ref_pos}"
|
|
)
|
|
|
|
elif op == "truncate":
|
|
new_size = rng.randint(0, max(1, ref.size + 100))
|
|
got = f.truncate(new_size)
|
|
expected = ref.truncate(new_size)
|
|
assert got == expected
|
|
|
|
elif op == "size":
|
|
assert f.size == ref.size
|
|
|
|
|
|
class TestFuzzMerkleConsistency:
|
|
"""Merkle tree consistency under random operations."""
|
|
|
|
@pytest.mark.parametrize("seed", range(10))
|
|
@pytest.mark.parametrize("c", FUZZ_CIPHERS, ids=cipher_id)
|
|
def test_merkle_rebuild_always_matches(self, seed, c):
|
|
"""After random writes, close/reopen/rebuild always reproduces root."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = c.random_key()
|
|
chunk_size = 1024
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=c,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=128,
|
|
) as f:
|
|
# Do random writes
|
|
for _ in range(rng.randint(5, 20)):
|
|
data = os.urandom(rng.randint(1, chunk_size * 3))
|
|
if rng.random() < 0.5 and f.size > 0:
|
|
offset = rng.randint(0, f.size)
|
|
f.pwrite(data, offset)
|
|
else:
|
|
f.write(data)
|
|
root = f.root_hash
|
|
|
|
with raf.open(storage, key, cipher=c, merkle=True, merkle_max_chunks=128) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
|
|
@pytest.mark.parametrize("seed", range(10))
|
|
def test_verify_root_after_random_ops(self, seed):
|
|
"""verify_root succeeds after random ops."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=128,
|
|
) as f:
|
|
for _ in range(rng.randint(3, 15)):
|
|
data = os.urandom(rng.randint(1, 3000))
|
|
f.write(data)
|
|
|
|
# Also some truncations
|
|
if f.size > 0 and rng.random() < 0.3:
|
|
f.truncate(rng.randint(0, f.size))
|
|
|
|
root = f.root_hash
|
|
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=128
|
|
) as f:
|
|
f.verify_root(root)
|
|
|
|
@pytest.mark.parametrize("seed", range(10))
|
|
def test_merkle_root_deterministic_across_write_patterns(self, seed):
|
|
"""Different write patterns for same content produce same root after rebuild."""
|
|
rng = random.Random(seed)
|
|
key = aegis128l.random_key()
|
|
total_size = rng.randint(100, 4000)
|
|
full_data = os.urandom(total_size)
|
|
|
|
# Pattern 1: single write
|
|
s1 = BytesIOStorage()
|
|
with raf.create(
|
|
s1,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=64,
|
|
) as f:
|
|
f.write(full_data)
|
|
root1 = f.root_hash
|
|
|
|
# Reopen and rebuild - must match
|
|
with raf.open(
|
|
s1, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root1
|
|
|
|
# Pattern 2: chunked writes to same file (truncate and rewrite)
|
|
with raf.open(
|
|
s1, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
f.truncate(0)
|
|
pos = 0
|
|
while pos < total_size:
|
|
chunk = rng.randint(1, min(500, total_size - pos))
|
|
f.write(full_data[pos : pos + chunk])
|
|
pos += chunk
|
|
root2 = f.root_hash
|
|
|
|
# Pattern 3: pwrite in random order to same file
|
|
with raf.open(
|
|
s1, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
f.truncate(0)
|
|
f.write(b"\x00" * total_size)
|
|
offsets = list(range(0, total_size, 100))
|
|
rng.shuffle(offsets)
|
|
for off in offsets:
|
|
end = min(off + 100, total_size)
|
|
f.pwrite(full_data[off:end], off)
|
|
root3 = f.root_hash
|
|
|
|
assert root1 == root2
|
|
assert root1 == root3
|
|
|
|
|
|
class TestFuzzMerkleTampering:
|
|
"""Detect tampering in ciphertext with Merkle verification."""
|
|
|
|
@pytest.mark.parametrize("seed", range(15))
|
|
def test_tamper_random_byte_detected(self, seed):
|
|
"""Flipping a random byte in the ciphertext is detected by rebuild or verify."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=64,
|
|
) as f:
|
|
data = os.urandom(rng.randint(100, 5000))
|
|
f.write(data)
|
|
original_root = f.root_hash
|
|
|
|
raw = storage._data
|
|
# Skip the 92-byte header to tamper only chunk data
|
|
if len(raw) > 100:
|
|
tamper_pos = rng.randint(92, len(raw) - 1)
|
|
raw[tamper_pos] ^= rng.randint(1, 255)
|
|
|
|
# Either rebuild fails (auth error) or verify detects mismatch
|
|
try:
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
result = f.merkle_verify()
|
|
# If rebuild succeeded, verify should find mismatch
|
|
# OR the root changed
|
|
assert result is not None or f.root_hash != original_root
|
|
except (ValueError, RuntimeError):
|
|
pass # Expected: tampered chunk fails authentication
|
|
|
|
@pytest.mark.parametrize("seed", range(10))
|
|
def test_tamper_multiple_bytes_detected(self, seed):
|
|
"""Flipping multiple random bytes is detected."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=64,
|
|
) as f:
|
|
f.write(os.urandom(rng.randint(2000, 8000)))
|
|
original_root = f.root_hash
|
|
|
|
raw = storage._data
|
|
# Tamper 3-10 random bytes
|
|
n_tampers = rng.randint(3, 10)
|
|
for _ in range(n_tampers):
|
|
if len(raw) > 100:
|
|
pos = rng.randint(92, len(raw) - 1)
|
|
raw[pos] ^= rng.randint(1, 255)
|
|
|
|
try:
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
result = f.merkle_verify()
|
|
assert result is not None or f.root_hash != original_root
|
|
except (ValueError, RuntimeError):
|
|
pass
|
|
|
|
|
|
class TestFuzzChunkBoundaries:
|
|
"""Test operations at exact chunk boundaries and crossing them."""
|
|
|
|
@pytest.mark.parametrize("chunk_size", [1024, 2048])
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_write_at_chunk_boundaries(self, chunk_size, seed):
|
|
"""Writes that start/end/cross chunk boundaries."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
ref = RefFile()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=64,
|
|
) as f:
|
|
for _i in range(20):
|
|
# Choose offsets near chunk boundaries
|
|
chunk_idx = rng.randint(0, 5)
|
|
boundary = chunk_idx * chunk_size
|
|
offset = boundary + rng.choice([-2, -1, 0, 1, 2, chunk_size // 2])
|
|
offset = max(0, offset)
|
|
size = rng.choice(
|
|
[
|
|
1,
|
|
chunk_size - 1,
|
|
chunk_size,
|
|
chunk_size + 1,
|
|
chunk_size * 2,
|
|
rng.randint(1, chunk_size * 3),
|
|
]
|
|
)
|
|
data = os.urandom(size)
|
|
|
|
f.pwrite(data, offset)
|
|
ref.pwrite(data, offset)
|
|
|
|
# Verify full content matches
|
|
assert f.size == ref.size
|
|
assert f.pread(f.size, 0) == bytes(ref._data)
|
|
|
|
root = f.root_hash
|
|
|
|
# Verify merkle
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_read_across_chunk_boundaries(self, seed):
|
|
"""Reads that span multiple chunks."""
|
|
rng = random.Random(seed)
|
|
chunk_size = 1024
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
total = chunk_size * 5
|
|
data = os.urandom(total)
|
|
|
|
with raf.create(storage, key, cipher=aegis128l, chunk_size=chunk_size) as f:
|
|
f.write(data)
|
|
|
|
# Random reads crossing boundaries
|
|
for _ in range(30):
|
|
offset = rng.randint(0, total - 1)
|
|
size = rng.randint(1, total - offset)
|
|
got = f.pread(size, offset)
|
|
assert got == data[offset : offset + size]
|
|
|
|
|
|
class TestFuzzTruncate:
|
|
"""Fuzz truncation combined with writes and merkle."""
|
|
|
|
@pytest.mark.parametrize("seed", range(10))
|
|
def test_truncate_and_rewrite(self, seed):
|
|
"""Truncate to various sizes then rewrite; data and merkle stay consistent."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
ref = RefFile()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=128,
|
|
) as f:
|
|
# Initial write
|
|
data = os.urandom(rng.randint(500, 5000))
|
|
f.write(data)
|
|
ref.write(data)
|
|
|
|
for _ in range(15):
|
|
op = rng.choice(["truncate", "write", "pwrite"])
|
|
if op == "truncate":
|
|
new_size = rng.randint(0, ref.size + 500)
|
|
f.truncate(new_size)
|
|
ref.truncate(new_size)
|
|
elif op == "write":
|
|
d = os.urandom(rng.randint(0, 2000))
|
|
f.write(d)
|
|
ref.write(d)
|
|
elif op == "pwrite":
|
|
d = os.urandom(rng.randint(0, 1000))
|
|
off = rng.randint(0, max(1, ref.size))
|
|
f.pwrite(d, off)
|
|
ref.pwrite(d, off)
|
|
|
|
assert f.size == ref.size
|
|
if ref.size > 0:
|
|
assert f.pread(ref.size, 0) == bytes(ref._data)
|
|
|
|
root = f.root_hash
|
|
|
|
# Reopen and verify
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=128
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
|
|
|
|
class TestFuzzEmptyAndTiny:
|
|
"""Edge cases with empty files and tiny writes."""
|
|
|
|
def test_empty_file_merkle(self):
|
|
"""Create empty file with merkle, root_hash is valid."""
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(storage, key, cipher=aegis128l, merkle=True) as f:
|
|
root = f.root_hash
|
|
assert root is not None
|
|
assert len(root) == 32
|
|
|
|
def test_single_byte_write_merkle(self):
|
|
"""Single byte write with merkle."""
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(storage, key, cipher=aegis128l, merkle=True) as f:
|
|
f.write(b"\x42")
|
|
root = f.root_hash
|
|
|
|
with raf.open(storage, key, cipher=aegis128l, merkle=True) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
assert f.pread(1, 0) == b"\x42"
|
|
|
|
def test_write_then_truncate_to_zero(self):
|
|
"""Write data then truncate to zero; merkle should be consistent."""
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=64,
|
|
) as f:
|
|
f.write(os.urandom(3000))
|
|
f.truncate(0)
|
|
assert f.size == 0
|
|
root = f.root_hash
|
|
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=64
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
|
|
@pytest.mark.parametrize("size", [1, 15, 16, 17, 1023, 1024, 1025])
|
|
def test_exact_sizes_roundtrip(self, size):
|
|
"""Files of exact sizes (around alignment) round-trip correctly."""
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
data = os.urandom(size)
|
|
|
|
with raf.create(
|
|
storage, key, cipher=aegis128l, chunk_size=1024, merkle=True
|
|
) as f:
|
|
f.write(data)
|
|
root = f.root_hash
|
|
|
|
with raf.open(storage, key, cipher=aegis128l, merkle=True) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.pread(size, 0) == data
|
|
|
|
|
|
class TestFuzzMultipleReopenCycles:
|
|
"""Test repeated open/write/close/reopen cycles."""
|
|
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_multiple_reopen_append(self, seed):
|
|
"""Open/write/close/reopen cycle many times; data accumulates correctly."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
ref = RefFile()
|
|
|
|
# Initial create
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=256,
|
|
) as f:
|
|
d = os.urandom(rng.randint(100, 500))
|
|
f.write(d)
|
|
ref.write(d)
|
|
|
|
for _cycle in range(8):
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=256
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
# Sometimes append, sometimes overwrite
|
|
if rng.random() < 0.6:
|
|
d = os.urandom(rng.randint(50, 300))
|
|
offset = f.size
|
|
f.pwrite(d, offset)
|
|
ref.pwrite(d, offset)
|
|
else:
|
|
if f.size > 0:
|
|
offset = rng.randint(0, f.size - 1)
|
|
d = os.urandom(rng.randint(1, 200))
|
|
f.pwrite(d, offset)
|
|
ref.pwrite(d, offset)
|
|
|
|
# Final verify
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=256
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.merkle_verify() is None
|
|
assert f.pread(ref.size, 0) == bytes(ref._data)
|
|
|
|
|
|
class TestFuzzAllVariants:
|
|
"""Test all 6 AEGIS variants with random operations."""
|
|
|
|
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
|
|
@pytest.mark.parametrize("seed", range(3))
|
|
def test_variant_random_ops_merkle(self, c, seed):
|
|
"""Each variant handles random ops + merkle correctly."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = c.random_key()
|
|
ref = RefFile()
|
|
|
|
with raf.create(
|
|
storage, key, cipher=c, chunk_size=1024, merkle=True, merkle_max_chunks=128
|
|
) as f:
|
|
for _ in range(20):
|
|
data = os.urandom(rng.randint(1, 2000))
|
|
if rng.random() < 0.4 and ref.size > 0:
|
|
off = rng.randint(0, ref.size)
|
|
f.pwrite(data, off)
|
|
ref.pwrite(data, off)
|
|
else:
|
|
f.write(data)
|
|
ref.write(data)
|
|
root = f.root_hash
|
|
|
|
with raf.open(storage, key, cipher=c, merkle=True, merkle_max_chunks=128) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
if ref.size > 0:
|
|
assert f.pread(ref.size, 0) == bytes(ref._data)
|
|
|
|
|
|
class TestFuzzCustomHasher:
|
|
"""Fuzz with custom Merkle hashers."""
|
|
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_blake2b_random_ops(self, seed):
|
|
"""Custom Blake2b hasher under random operations."""
|
|
rng = random.Random(seed)
|
|
hasher = Blake2bMerkleHasher()
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
ref = RefFile()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=hasher,
|
|
merkle_max_chunks=128,
|
|
) as f:
|
|
for _ in range(25):
|
|
data = os.urandom(rng.randint(1, 2000))
|
|
f.write(data)
|
|
ref.write(data)
|
|
root = f.root_hash
|
|
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=hasher, merkle_max_chunks=128
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
assert f.pread(ref.size, 0) == bytes(ref._data)
|
|
|
|
def test_sha256_vs_blake2b_differ(self):
|
|
"""SHA256 and Blake2b hashers produce different roots for same data."""
|
|
key = aegis128l.random_key()
|
|
data = os.urandom(2000)
|
|
|
|
sha_storage = BytesIOStorage()
|
|
with raf.create(
|
|
sha_storage, key, cipher=aegis128l, chunk_size=1024, merkle=True
|
|
) as f:
|
|
f.write(data)
|
|
sha_root = f.root_hash
|
|
|
|
b2_storage = BytesIOStorage()
|
|
with raf.create(
|
|
b2_storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=Blake2bMerkleHasher(),
|
|
) as f:
|
|
f.write(data)
|
|
b2_root = f.root_hash
|
|
|
|
assert sha_root != b2_root
|
|
|
|
|
|
class TestFuzzWrongKey:
|
|
"""Fuzz: opening files with wrong keys."""
|
|
|
|
@pytest.mark.parametrize("seed", range(10))
|
|
def test_wrong_key_rejected(self, seed):
|
|
"""Random data written, then reopened with wrong key fails."""
|
|
rng = random.Random(seed)
|
|
c = rng.choice(CIPHERS)
|
|
storage = BytesIOStorage()
|
|
key = c.random_key()
|
|
|
|
with raf.create(storage, key, cipher=c, chunk_size=1024) as f:
|
|
f.write(os.urandom(rng.randint(100, 3000)))
|
|
|
|
wrong_key = c.random_key()
|
|
assert wrong_key != key # Negligible collision probability
|
|
with pytest.raises(ValueError, match="authentication failed"):
|
|
raf.open(storage, wrong_key, cipher=c)
|
|
|
|
|
|
class TestFuzzFileStorage:
|
|
"""Fuzz test RAF with real file I/O."""
|
|
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_file_storage_random_ops(self, seed, tmp_path):
|
|
"""Random ops on file-backed storage produce correct results."""
|
|
rng = random.Random(seed)
|
|
path = tmp_path / f"fuzz_{seed}.aegis"
|
|
key = aegis128l.random_key()
|
|
ref = RefFile()
|
|
|
|
with FileStorage(path, "w+b") as fs:
|
|
with raf.create(
|
|
fs,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=1024,
|
|
merkle=True,
|
|
merkle_max_chunks=128,
|
|
) as f:
|
|
for _ in range(20):
|
|
data = os.urandom(rng.randint(1, 1500))
|
|
if rng.random() < 0.3 and ref.size > 0:
|
|
off = rng.randint(0, ref.size)
|
|
f.pwrite(data, off)
|
|
ref.pwrite(data, off)
|
|
else:
|
|
f.write(data)
|
|
ref.write(data)
|
|
root = f.root_hash
|
|
|
|
# Reopen from file and verify
|
|
with FileStorage(path, "r+b") as fs:
|
|
with raf.open(
|
|
fs,
|
|
key,
|
|
cipher=aegis128l,
|
|
merkle=True,
|
|
merkle_max_chunks=128,
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
if ref.size > 0:
|
|
assert f.pread(ref.size, 0) == bytes(ref._data)
|
|
|
|
|
|
class TestFuzzRafOpen:
|
|
"""Fuzz probe + explicit-cipher open with random data.
|
|
|
|
pyaegis tested raf_open auto-detection; our raf.open() requires the
|
|
cipher, so we check probe() reports the right alg_id instead.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("c", CIPHERS, ids=cipher_id)
|
|
def test_probe_reports_variant(self, c):
|
|
"""probe() correctly reports each variant after random writes."""
|
|
storage = BytesIOStorage()
|
|
key = c.random_key()
|
|
data = os.urandom(random.randint(100, 3000))
|
|
|
|
with raf.create(storage, key, cipher=c, chunk_size=1024, merkle=True) as f:
|
|
f.write(data)
|
|
root = f.root_hash
|
|
|
|
assert raf.probe(storage).alg_id == c.RAF_ALG_ID
|
|
|
|
with raf.open(storage, key, cipher=c, merkle=True) as f:
|
|
f.verify_root(root)
|
|
assert f.pread(len(data), 0) == data
|
|
|
|
|
|
class TestFuzzMerkleMaxChunks:
|
|
"""Test merkle_max_chunks boundary."""
|
|
|
|
def test_write_exactly_max_chunks(self):
|
|
"""Writing exactly max_chunks chunks is OK."""
|
|
chunk_size = 1024
|
|
max_chunks = 4
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=max_chunks,
|
|
) as f:
|
|
# Write exactly max_chunks * chunk_size bytes
|
|
data = os.urandom(chunk_size * max_chunks)
|
|
f.write(data)
|
|
assert f.root_hash is not None
|
|
|
|
def test_write_exceeds_max_chunks(self):
|
|
"""Writing past max_chunks raises ValueError."""
|
|
chunk_size = 1024
|
|
max_chunks = 4
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=max_chunks,
|
|
) as f:
|
|
data = os.urandom(chunk_size * (max_chunks + 1))
|
|
with pytest.raises(ValueError, match="merkle_max_chunks"):
|
|
f.write(data)
|
|
|
|
@pytest.mark.parametrize("max_chunks", [1, 2, 3, 8])
|
|
def test_boundary_max_chunks(self, max_chunks):
|
|
"""Boundary test: fill exactly to max, verify merkle still works."""
|
|
chunk_size = 1024
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=max_chunks,
|
|
) as f:
|
|
data = os.urandom(chunk_size * max_chunks)
|
|
f.write(data)
|
|
root = f.root_hash
|
|
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=max_chunks
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
|
|
|
|
class TestFuzzReadInto:
|
|
"""Fuzz test read_into with random patterns."""
|
|
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_read_into_matches_read(self, seed):
|
|
"""read_into produces same result as read for same offset/size."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
data = os.urandom(rng.randint(500, 5000))
|
|
|
|
with raf.create(storage, key, cipher=aegis128l, chunk_size=1024) as f:
|
|
f.write(data)
|
|
|
|
for _ in range(30):
|
|
offset = rng.randint(0, len(data))
|
|
size = rng.randint(0, len(data) - offset + 100)
|
|
size = min(size, len(data))
|
|
|
|
f.seek(offset)
|
|
read_result = f.read(size)
|
|
|
|
buf = bytearray(size)
|
|
f.seek(offset)
|
|
n = f.read_into(buf, offset)
|
|
|
|
assert bytes(buf[:n]) == read_result
|
|
|
|
|
|
class TestFuzzSeekTell:
|
|
"""Fuzz seek/tell consistency."""
|
|
|
|
@pytest.mark.parametrize("seed", range(5))
|
|
def test_seek_tell_consistency(self, seed):
|
|
"""seek returns same value as subsequent tell."""
|
|
rng = random.Random(seed)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
|
|
with raf.create(storage, key, cipher=aegis128l, chunk_size=1024) as f:
|
|
f.write(os.urandom(rng.randint(100, 3000)))
|
|
|
|
for _ in range(50):
|
|
whence = rng.choice([0, 1, 2])
|
|
if whence == 0:
|
|
off = rng.randint(0, f.size + 100)
|
|
elif whence == 1:
|
|
off = rng.randint(-f.tell(), f.size + 50)
|
|
else:
|
|
off = rng.randint(-f.size, 50)
|
|
|
|
pos = f.seek(off, whence)
|
|
assert f.tell() == pos
|
|
|
|
|
|
class TestFuzzLargeFile:
|
|
"""Test with larger data sizes."""
|
|
|
|
def test_large_sequential_writes_merkle(self):
|
|
"""100KB of data in sequential writes with merkle verification."""
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
total = 100 * 1024
|
|
chunk_size = 4096
|
|
ref = bytearray()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=256,
|
|
) as f:
|
|
written = 0
|
|
while written < total:
|
|
piece = os.urandom(random.randint(1, 8192))
|
|
f.write(piece)
|
|
ref.extend(piece)
|
|
written += len(piece)
|
|
root = f.root_hash
|
|
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=256
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
assert f.pread(len(ref), 0) == bytes(ref)
|
|
|
|
def test_large_random_access_merkle(self):
|
|
"""Random reads/writes across 100KB file with merkle."""
|
|
rng = random.Random(42)
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
chunk_size = 4096
|
|
ref = RefFile()
|
|
|
|
with raf.create(
|
|
storage,
|
|
key,
|
|
cipher=aegis128l,
|
|
chunk_size=chunk_size,
|
|
merkle=True,
|
|
merkle_max_chunks=256,
|
|
) as f:
|
|
# Initial fill
|
|
initial = os.urandom(50 * 1024)
|
|
f.write(initial)
|
|
ref.write(initial)
|
|
|
|
# Random overwrites
|
|
for _ in range(100):
|
|
off = rng.randint(0, ref.size - 1)
|
|
d = os.urandom(rng.randint(1, 4096))
|
|
f.pwrite(d, off)
|
|
ref.pwrite(d, off)
|
|
|
|
# Verify content
|
|
assert f.size == ref.size
|
|
# Read in chunks to verify
|
|
for off in range(0, ref.size, chunk_size):
|
|
size = min(chunk_size, ref.size - off)
|
|
assert f.pread(size, off) == ref.pread(size, off)
|
|
|
|
root = f.root_hash
|
|
|
|
with raf.open(
|
|
storage, key, cipher=aegis128l, merkle=True, merkle_max_chunks=256
|
|
) as f:
|
|
f.merkle_rebuild()
|
|
assert f.root_hash == root
|
|
assert f.merkle_verify() is None
|
|
|
|
|
|
class TestFuzzConcurrentStorageOps:
|
|
"""Test that storage state is consistent across RAF operations."""
|
|
|
|
def test_probe_after_random_writes(self):
|
|
"""raf.probe returns consistent info after random writes."""
|
|
storage = BytesIOStorage()
|
|
key = aegis128l.random_key()
|
|
chunk_size = 2048
|
|
|
|
with raf.create(storage, key, cipher=aegis128l, chunk_size=chunk_size) as f:
|
|
for _ in range(10):
|
|
f.write(os.urandom(random.randint(100, 3000)))
|
|
expected_size = f.size
|
|
|
|
_alg_id, probed_chunk, probed_size = raf.probe(storage)
|
|
assert probed_chunk == chunk_size
|
|
assert probed_size == expected_size
|