toeplitz-aes: new chunker (tabulated LFSR/Toeplitz UHF + AES-128)

Third UHF-then-PRF chunker, and on the evidence the best construction of
the three: the universal hash is a tabulated LFSR-based Toeplitz hash
(Krawczyk, CRYPTO '94) - digest = sum_j x^(63-j) * T[b_j] over
GF(2)[x] mod P - with a secret random table T of 256 uint64 (2 KiB of
key material) and a FIXED PUBLIC irreducible P of degree 64
(x^64 + x^4 + x^3 + x + 1, the CLHASH polynomial, top bit implicit).

Collision bound: two distinct 64-byte windows collide with probability
EXACTLY 2^-64 over T - optimal for a 64-bit digest and unconditional
(rabin-aes: ~2^-55 probabilistic over the sampled P; goldilocks-aes:
~2^-58). Sketch: the difference is sum_v c_v * T[v] where some c_v is a
nonzero polynomial of degree < 64, hence an invertible element mod the
irreducible degree-64 P, making the sum uniform. This needs the fixed
64-byte window (distinct powers x^0..x^63); a plain-rotation variant
(keyed buzhash) fails - rotations satisfy R^64 = I, so e.g. two
all-same-byte windows collide with probability 1/2.

The rolling step d' = d*x ^ x^64*T[out] ^ T[in] reduces via a branchless
shift+masked-XOR and indexes tables only by plaintext bytes: no loads and
no secret-dependent memory access on the serial chain. Setup needs no
irreducibility sampling (P is public; the secrecy is the table).

Same C kernel architecture as rabin-aes/goldilocks-aes (two even/odd
lanes, grouped AES on arm64/AES-NI, batched OpenSSL EVP, bit-identical
paths), fully wired (constants, ChunkerParams, get_chunker, completion,
borg benchmark cpu, docs, tests with frozen golden chunk points),
verified end-to-end with repo-create/create/extract.

Measured (1 GiB random, 19,23,21, 5 runs x 2 reps): hw 666/713 (nc2)
MB/s - on par with rabin-aes 661/698; EVP 424/450 vs 405/434 (~+5%).
The tie on the hw path is itself a finding: at ~700 MB/s the grouped-AES
path is AES/transfer-bound, not roll-chain-bound, so the cheaper roller
only shows on the EVP path. Equal speed, strictly better bound.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Thomas Waldmann
2026-08-04 15:44:28 +02:00
co-authored by Claude Fable 5
parent bb70e32187
commit eebdd3a69c
15 changed files with 1317 additions and 3 deletions
+1
View File
@@ -13,6 +13,7 @@ src/borg/chunkers/buzhash64.c
src/borg/chunkers/fastcdc.c
src/borg/chunkers/rabin_aes.c
src/borg/chunkers/goldilocks_aes.c
src/borg/chunkers/toeplitz_aes.c
src/borg/chunkers/reader.c
src/borg/checksums.c
src/borg/platform/darwin.c
+3
View File
@@ -259,6 +259,9 @@ New features:
- goldilocks-aes: new chunker, like rabin-aes but with the reference universal
hash of eprint 2025/558 (Goldilocks prime-field polynomial hash); about half
the rabin-aes speed, mainly a comparison baseline
- toeplitz-aes: new chunker, like rabin-aes but with a tabulated LFSR/Toeplitz
hash as the universal hash (secret 2 KiB table, fixed public polynomial);
optimal 2^-64 collision bound, fastest of the three AES chunkers
- borg keys:
- locate the borg key automatically in the key directory or in the repository, #9743
+22
View File
@@ -416,6 +416,9 @@ Borg has these chunkers (the default is "fastcdc"):
hash over the Goldilocks prime field (the reference construction of the
underlying paper); about half the rabin-aes speed, mainly a comparison
baseline.
- "toeplitz-aes": like "rabin-aes", but the universal hash is a tabulated
LFSR/Toeplitz hash (secret 2 KiB table, fixed public polynomial); same
speed as "rabin-aes" with the best collision bound of the three.
For some more general usage hints see also ``--chunker-params``.
@@ -564,6 +567,25 @@ well-understood comparison baseline.
The window size is fixed at 64 bytes. NC_LEVEL is the normalized chunking
level (0 disables it); 2 is a good default. E.g.: ``goldilocks-aes,19,23,21,2``.
"toeplitz-aes" chunker
++++++++++++++++++++++
Like "rabin-aes", but the universal hash is a tabulated LFSR-based Toeplitz
hash (Krawczyk, CRYPTO '94): the digest of the 64-byte window is
sum_j x^(63-j) * T[b_j] over GF(2)[x] mod P, where T is a secret random
table of 256 64-bit values (2 KiB of key material) and P is a *fixed public*
irreducible polynomial of degree 64. The AES-128 PRF layer is the same as
for "rabin-aes". Two distinct windows collide with probability exactly
2^-64 over the choice of T - the best possible bound for a 64-bit digest,
and unconditional (no secret polynomial sampling). The rolling update
contains no secret-dependent memory access. Speed is on par with
"rabin-aes". See :doc:`chunker` for a comparison of all chunkers.
``borg create --chunker-params toeplitz-aes,CHUNK_MIN_EXP,CHUNK_MAX_EXP,HASH_MASK_BITS,NC_LEVEL``
The window size is fixed at 64 bytes. NC_LEVEL is the normalized chunking
level (0 disables it); 2 is a good default. E.g.: ``toeplitz-aes,19,23,21,2``.
.. _cache:
The files cache
+2 -2
View File
@@ -131,7 +131,7 @@ def chunk_stats(algo, data, min_exp, max_exp, mask_bits, win, nc_level=0, normal
if algo == "buzhash64":
params.append(nc_level) # nc_level is a positional param
kw["normal_size"] = normal_size
elif algo in ("fastcdc", "rabin-aes", "goldilocks-aes"):
elif algo in ("fastcdc", "rabin-aes", "goldilocks-aes", "toeplitz-aes"):
params = [min_exp, max_exp, mask_bits, nc_level] # window-less param shape
kw["normal_size"] = normal_size
chunker = get_chunker(algo, *params, **kw)
@@ -292,7 +292,7 @@ def main():
variants = []
for algo in args.algo:
variants.append((algo, 0))
if algo in ("buzhash64", "fastcdc", "rabin-aes", "goldilocks-aes") and args.nc_level > 0:
if algo in ("buzhash64", "fastcdc", "rabin-aes", "goldilocks-aes", "toeplitz-aes") and args.nc_level > 0:
variants.append((algo, args.nc_level))
for algo, nc in variants:
+11
View File
@@ -61,6 +61,8 @@ rabin_aes_source = "src/borg/chunkers/rabin_aes.pyx"
rabin_aes_impl_source = "src/borg/chunkers/rabin_aes_impl.c"
goldilocks_aes_source = "src/borg/chunkers/goldilocks_aes.pyx"
goldilocks_aes_impl_source = "src/borg/chunkers/goldilocks_aes_impl.c"
toeplitz_aes_source = "src/borg/chunkers/toeplitz_aes.pyx"
toeplitz_aes_impl_source = "src/borg/chunkers/toeplitz_aes_impl.c"
reader_source = "src/borg/chunkers/reader.pyx"
hashindex_source = "src/borg/hashindex.pyx"
item_source = "src/borg/item.pyx"
@@ -81,6 +83,7 @@ cython_sources = [
fastcdc_source,
rabin_aes_source,
goldilocks_aes_source,
toeplitz_aes_source,
reader_source,
hashindex_source,
item_source,
@@ -186,6 +189,13 @@ if not on_rtd:
dict(extra_compile_args=cflags),
)
# toeplitz-aes likewise (same PRF layer, different rolling hash)
toeplitz_aes_ext_kwargs = members_appended(
dict(sources=[toeplitz_aes_source, toeplitz_aes_impl_source], include_dirs=["src/borg/chunkers"]),
crypto_ext_lib,
dict(extra_compile_args=cflags),
)
compress_ext_kwargs = members_appended(
dict(sources=[compress_source]),
lib_ext_kwargs(pc, "BORG_LIBLZ4_PREFIX", "lz4", "liblz4", ">= 1.7.0"),
@@ -214,6 +224,7 @@ if not on_rtd:
Extension("borg.chunkers.fastcdc", [fastcdc_source], extra_compile_args=cflags),
Extension("borg.chunkers.rabin_aes", **rabin_aes_ext_kwargs),
Extension("borg.chunkers.goldilocks_aes", **goldilocks_aes_ext_kwargs),
Extension("borg.chunkers.toeplitz_aes", **toeplitz_aes_ext_kwargs),
Extension("borg.chunkers.reader", [reader_source], extra_compile_args=cflags),
]
+7
View File
@@ -209,6 +209,13 @@ class BenchmarkMixIn:
"chunkit(ch)",
locals(),
),
# toeplitz-aes (UHF-then-PRF, tabulated Toeplitz hash); table creation in setup
(
"toeplitz-aes,19,23,21,2",
"ch = get_chunker('toeplitz-aes', 19, 23, 21, 2, sparse=False)",
"chunkit(ch)",
locals(),
),
("fixed,1048576", "ch = get_chunker('fixed', 1048576, sparse=False)", "chunkit(ch)", locals()),
]:
dt = timeit(func, setup, number=number_default, globals=vars)
+1
View File
@@ -716,6 +716,7 @@ class CompletionMixIn:
"fastcdc,19,23,21,2",
"rabin-aes,19,23,21,2",
"goldilocks-aes,19,23,21,2",
"toeplitz-aes,19,23,21,2",
]
chunker_params_choices_str = " ".join(chunker_params_choices)
+11
View File
@@ -3,6 +3,7 @@ from .buzhash64 import ChunkerBuzHash64
from .fastcdc import ChunkerFastCDC
from .rabin_aes import ChunkerRabinAES
from .goldilocks_aes import ChunkerGoldilocksAES
from .toeplitz_aes import ChunkerToeplitzAES
from .failing import ChunkerFailing
from .fixed import ChunkerFixed
from .reader import * # noqa
@@ -48,6 +49,16 @@ def get_chunker(algo, *params, **kw):
else b"\0" * 32
)
return ChunkerGoldilocksAES(gl_key, *params, normal_size=kw.get("normal_size", 0), sparse=sparse)
if algo == "toeplitz-aes":
# like rabin-aes, but with a tabulated LFSR/Toeplitz hash as the UHF
# (secret 2 KiB table, fixed public polynomial). Same param shape:
# (chunk_min_exp, chunk_max_exp, hash_mask_bits, nc_level).
tp_key = (
key.derive_key(salt=b"", domain=b"toeplitz-aes", size=32, from_id_key=True)
if key is not None
else b"\0" * 32
)
return ChunkerToeplitzAES(tp_key, *params, normal_size=kw.get("normal_size", 0), sparse=sparse)
if algo == "fixed":
return ChunkerFixed(*params, sparse=sparse)
if algo == "fail":
+24
View File
@@ -0,0 +1,24 @@
from collections.abc import Iterator
from typing import BinaryIO
from .reader import fmap_entry
_P: int
def toeplitz_aes_get_table(key: bytes) -> list[int]: ...
def toeplitz_aes_get_tables(key: bytes) -> tuple[list[int], list[int], list[int], list[int]]: ...
def toeplitz_aes_digest64(key: bytes, window: bytes) -> int: ...
class ChunkerToeplitzAES:
kernel: str
def __init__(
self,
key: bytes,
chunk_min_exp: int,
chunk_max_exp: int,
hash_mask_bits: int,
nc_level: int = 0,
normal_size: int = 0,
sparse: bool = False,
) -> None: ...
def chunkify(self, fd: BinaryIO = None, fh: int = -1, fmap: list[fmap_entry] = None) -> Iterator: ...
+389
View File
@@ -0,0 +1,389 @@
# cython: language_level=3
import os
import cython
import time
from cpython.bytes cimport PyBytes_AsString
from libc.stdint cimport uint8_t, uint64_t, int64_t
from libc.stdlib cimport malloc, free
from libc.string cimport memcpy, memmove, memset
from ..crypto.low_level import CSPRNG
from ..constants import CH_DATA, CH_ALLOC, CH_HOLE, zeros
from .reader import FileReader, Chunk
# toeplitz-aes: a "Chk-PHTE" style chunker (UHF-then-PRF, like rabin-aes and
# goldilocks-aes - see rabin_aes.pyx for the full motivation) whose UHF is a
# tabulated LFSR-based Toeplitz hash (Krawczyk, CRYPTO '94 - "LFSR-based
# hashing and authentication"):
#
# 1. the rolling *universal hash* over the 64-byte window is
# digest = sum_j x^(63-j) * T[b_j] over GF(2)[x] mod P,
# where T is a secret random table of 256 uint64 values (2 KiB of key
# material) and P is a FIXED PUBLIC irreducible polynomial of degree 64
# (x^64 + x^4 + x^3 + x + 1, the x^64 bit implicit; the same polynomial
# CLHASH uses for GF(2^64)). Two distinct windows collide with
# probability EXACTLY 2^-64 over the choice of T: their difference is
# sum_v c_v * T[v] with at least one coefficient c_v a nonzero
# polynomial of degree < 64 - which mod an irreducible degree-64 P is an
# invertible field element, making the sum uniform. This bound is
# unconditional (P is public; no irreducibility sampling at setup) and
# optimal for a 64-bit digest - better than rabin-aes (~2^-55) and
# goldilocks-aes (~2^-58).
# NOTE the window must be exactly 64 bytes: the argument needs distinct
# powers x^0..x^63 of degree < 64 per position. (A plain-rotation
# variant - keyed buzhash - fails here: rotations satisfy R^64 = I, so
# e.g. two all-same-byte windows collide with probability 1/2.)
# 2. a *PRF* (AES-128 with a secret key) is applied to the digest; the cut
# decision looks only at the PRF output (low bits of the first 8
# ciphertext bytes, little-endian): cut iff (AES(digest) & mask) == 0.
#
# Performance: same batching structure as rabin-aes (two even/odd rolling
# lanes, grouped AES). The rolling step is d' = d*x ^ x^64*T[out] ^ T[in],
# where d*x mod P is a shift plus a branchless masked XOR - and both table
# lookups are indexed by *plaintext* bytes. The serial dependency chain thus
# contains no memory loads at all (rabin-aes has a digest-indexed reduction
# lookup in its chain), which also means no secret-dependent memory access
# in the hot loop (cache-side-channel posture like goldilocks-aes).
cdef extern from "toeplitz_aes_impl.h":
ctypedef struct TP_CTX:
pass
int TP_TABLES
TP_CTX *tp_new(const uint64_t *tables, const uint8_t *aes_key, int force_sw)
void tp_free(TP_CTX *ctx)
const char *tp_kind(const TP_CTX *ctx)
uint64_t tp_digest64(const TP_CTX *ctx, const uint8_t *q)
int64_t tp_scan(TP_CTX *ctx, const uint8_t *p, size_t n, uint64_t *digest, uint64_t mask)
# --- GF(2)[x] helpers (pure Python, used at chunker setup only) ----------
# the fixed public irreducible polynomial P = x^64 + x^4 + x^3 + x + 1
_P = (1 << 64) | 0x1B
_WINDOW_SIZE = 64 # bytes; hard requirement of the collision bound, see above
def _poly_mod(v):
"""v mod P in GF(2)[x]."""
while v.bit_length() - 1 >= 64:
v ^= _P << (v.bit_length() - 1 - 64)
return v
def _build_tables(t):
"""Build the rolling tables for T (order must match TP_TABLES in the C kernel).
in0_tbl[b] = T[b] (incoming byte, newest position)
in1_tbl[b] = (x * T[b]) mod P (stride-2, older incoming byte)
out64_tbl[b] = (x^64 * T[b]) mod P (removal of the byte leaving the window)
out65_tbl[b] = (x^65 * T[b]) mod P (stride-2 removal, older leaving byte)
Tables 1..3 are algebraic combinations of T and P; they let the C kernel
run two independent even/odd rolling lanes (see toeplitz_aes_impl.c)
without changing any cut point.
"""
in0_tbl = list(t)
in1_tbl = [_poly_mod(v << 1) for v in t]
out64_tbl = [_poly_mod(v << 64) for v in t]
out65_tbl = [_poly_mod(v << 65) for v in t]
return in0_tbl, in1_tbl, out64_tbl, out65_tbl
def _derive(bytes key):
"""Derive (aes_key, T, tables) deterministically from a 256-bit key.
Frozen derivation order (changing it changes all cut points and thus breaks
deduplication against existing repos): first the 16-byte AES key, then the
table T as one 2048-byte block, split into 256 little-endian uint64.
T must be uniform (not e.g. bit-balanced): the 2^-64 collision bound
needs independent uniform entries.
"""
rng = CSPRNG(key)
aes_key = rng.random_bytes(16)
blob = rng.random_bytes(8 * 256)
t = [int.from_bytes(blob[8 * i : 8 * i + 8], "little") for i in range(256)]
tables = _build_tables(t)
return aes_key, t, tables
cdef class ChunkerToeplitzAES:
"""
Content-Defined Chunker, variable chunk sizes, UHF-then-PRF cut decision.
Rolling tabulated Toeplitz hash (secret 2 KiB table, fixed public
polynomial, 64-byte window) post-processed with AES-128; the cut decision
only sees the AES output, so observed chunk boundaries do not leak usable
information about the chunking secret (see module docstring).
"""
cdef uint64_t chunk_mask
cdef uint64_t mask_s, mask_l # normalized chunking: strict / loose masks
cdef size_t normal_size # chunk length at which we switch mask_s -> mask_l
cdef int nc_level # normalized chunking level (0 = disabled)
cdef TP_CTX* ctx
cdef uint8_t* data
cdef object _fd
cdef int fh
cdef int done, eof
cdef size_t min_size, buf_size, remaining, position, last
cdef long long bytes_read, bytes_yielded
cdef readonly float chunking_time
cdef object file_reader
cdef size_t reader_block_size
cdef bint sparse
def __cinit__(self, bytes key, int chunk_min_exp, int chunk_max_exp, int hash_mask_bits, int nc_level=0, size_t normal_size=0, bint sparse=False):
cdef uint64_t c_tables[4 * 256]
cdef int i, t
self.ctx = NULL
self.data = NULL
min_size = 1 << chunk_min_exp
max_size = 1 << chunk_max_exp
assert max_size <= len(zeros)
assert min_size + 1 <= max_size, "too small max_size"
# the rolling window needs 64 bytes of in-chunk history at the first
# possible cut position (min_size), so the minimum chunk size must be
# at least the window size:
assert chunk_min_exp >= 6, "chunk_min_exp must be >= 6 (2^6 = 64 = window size)"
self.chunk_mask = (1ULL << hash_mask_bits) - 1
self.min_size = min_size
# Normalized chunking, identical structure to the fastcdc chunker (see there);
# the AES output is uniform, so contiguous low-bit masks are fine.
assert nc_level >= 0
assert hash_mask_bits - nc_level >= 1, "nc_level too large for hash_mask_bits"
assert hash_mask_bits + nc_level <= 48, "nc_level too large for hash_mask_bits"
self.nc_level = nc_level
if nc_level:
self.mask_s = (1ULL << (hash_mask_bits + nc_level)) - 1
self.mask_l = (1ULL << (hash_mask_bits - nc_level)) - 1
self.normal_size = normal_size if normal_size else ((1ULL << hash_mask_bits) - (1ULL << (hash_mask_bits - nc_level)))
else:
self.mask_s = self.chunk_mask
self.mask_l = self.chunk_mask
self.normal_size = 0
aes_key, table, tables = _derive(key)
assert len(tables) == TP_TABLES
for t in range(len(tables)):
for i in range(256):
c_tables[t * 256 + i] = tables[t][i]
force_sw = os.environ.get("BORG_TOEPLITZ_AES_FORCE_EVP", "") not in ("", "0")
self.ctx = tp_new(c_tables, aes_key, 1 if force_sw else 0)
if self.ctx == NULL:
raise MemoryError("Failed to set up toeplitz-aes kernel")
self.buf_size = max_size
self.data = <uint8_t*>malloc(self.buf_size)
if self.data == NULL:
raise MemoryError("Failed to allocate chunker buffer")
self.fh = -1
self.done = 0
self.eof = 0
self.remaining = 0
self.position = 0
self.last = 0
self.bytes_read = 0
self.bytes_yielded = 0
self._fd = None
self.chunking_time = 0.0
self.reader_block_size = 1024 * 1024
self.sparse = sparse
def __dealloc__(self):
if self.ctx != NULL:
tp_free(self.ctx)
self.ctx = NULL
if self.data != NULL:
free(self.data)
self.data = NULL
@property
def kernel(self):
"""Which scan kernel this chunker uses: 'aes-arm64', 'aes-ni' or 'evp'."""
return (<bytes>tp_kind(self.ctx)).decode("ascii")
cdef int fill(self) except 0:
"""Fill the chunker's buffer with more data."""
cdef ssize_t n
cdef object chunk
memmove(self.data, self.data + self.last, self.position + self.remaining - self.last)
self.position -= self.last
self.last = 0
n = self.buf_size - self.position - self.remaining
if self.eof or n == 0:
return 1
chunk = self.file_reader.read(n)
n = chunk.meta["size"]
if n > 0:
if chunk.meta["allocation"] == CH_DATA:
memcpy(self.data + self.position + self.remaining, <const unsigned char*>PyBytes_AsString(chunk.data), n)
else:
memset(self.data + self.position + self.remaining, 0, n)
self.remaining += n
self.bytes_read += n
else:
self.eof = 1
return 1
cdef object process(self) except *:
"""Process the chunker's buffer and return the next chunk."""
cdef uint64_t digest = 0, mask, mask_s = self.mask_s, mask_l = self.mask_l
cdef int nc_level = self.nc_level
cdef size_t n, old_last, min_size = self.min_size
cdef size_t normal_size = self.normal_size, normal_pos, chunk_len, span, did
cdef int64_t r
cdef uint8_t* p
cdef uint8_t* stop
if self.done:
if self.bytes_read == self.bytes_yielded:
raise StopIteration
else:
raise Exception("chunkifier byte count mismatch")
# ensure at least min_size + 1 bytes are buffered, or we are at eof
while self.remaining < min_size + 1 and not self.eof:
self.fill()
# at eof with only a remainder (< min_size + 1): emit it as the final chunk
if self.eof and self.remaining < min_size + 1:
self.done = 1
if self.remaining:
old_last = self.last
self.position += self.remaining
self.last = self.position
n = self.last - old_last
self.remaining = 0
self.bytes_yielded += n
return memoryview((self.data + old_last)[:n])
else:
if self.bytes_read == self.bytes_yielded:
raise StopIteration
else:
raise Exception("chunkifier byte count mismatch")
# skip the sub-minimum region (no cut allowed below min_size), then scan.
# warm up the rolling digest over the 64 bytes preceding the first
# scan position (they are within the current chunk, since min_size >= 64).
self.position += min_size
self.remaining -= min_size
digest = tp_digest64(self.ctx, self.data + self.position - 64)
while True:
chunk_len = self.position - self.last
mask = mask_s if (nc_level and chunk_len < normal_size) else mask_l
if self.remaining == 0:
if self.eof:
break # cut at end of data
self.fill()
if self.remaining == 0:
break # buffer full -> chunk reached max_size -> forced cut
continue
p = self.data + self.position
stop = p + self.remaining
if nc_level and chunk_len < normal_size:
# do not scan past the strict->loose transition; re-evaluate the mask there
normal_pos = self.last + normal_size
if (self.data + normal_pos) < stop:
stop = self.data + normal_pos
span = stop - p
r = tp_scan(self.ctx, p, span, &digest, mask)
if r == -2:
raise Exception("toeplitz-aes: OpenSSL EVP encryption failed")
if r >= 0:
did = <size_t>r + 1 # cut right after the position that matched
self.position += did
self.remaining -= did
break
else:
self.position += span
self.remaining -= span
old_last = self.last
self.last = self.position
n = self.last - old_last
self.bytes_yielded += n
return memoryview((self.data + old_last)[:n])
def chunkify(self, fd, fh=-1, fmap=None):
"""
Cut a file into chunks.
:param fd: Python file object
:param fh: OS-level file handle (if available),
defaults to -1 which means not to use OS-level fd.
:param fmap: a file map, same format as generated by sparsemap
"""
self._fd = fd
self.fh = fh
self.file_reader = FileReader(fd=fd, fh=fh, read_size=self.reader_block_size, sparse=self.sparse, fmap=fmap)
self.done = 0
self.remaining = 0
self.bytes_read = 0
self.bytes_yielded = 0
self.position = 0
self.last = 0
self.eof = 0
return self
def __iter__(self):
return self
def __next__(self):
started_chunking = time.monotonic()
data = self.process()
got = len(data)
if zeros.startswith(data):
data = None
allocation = CH_ALLOC
else:
allocation = CH_DATA
self.chunking_time += time.monotonic() - started_chunking
return Chunk(data, size=got, allocation=allocation)
def toeplitz_aes_get_table(bytes key):
"""Get the secret table T derived from <key> (for tests)."""
aes_key, t, tables = _derive(key)
return t
def toeplitz_aes_get_tables(bytes key):
"""Get the (in0, in1, out64, out65) rolling tables derived from <key> (for tests)."""
aes_key, t, tables = _derive(key)
return tables
def toeplitz_aes_digest64(bytes key, bytes window):
"""C-kernel digest of a 64-byte window for the table derived from <key> (for tests).
Lets tests verify the kernel's GF(2)[x] mod P arithmetic against a
pure-Python big-int reference.
"""
cdef uint64_t c_tables[4 * 256]
cdef uint8_t aes_key_c[16]
cdef TP_CTX *ctx
cdef uint64_t d
cdef int i, t
assert len(window) == _WINDOW_SIZE
aes_key, tt, tables = _derive(key)
for t in range(len(tables)):
for i in range(256):
c_tables[t * 256 + i] = tables[t][i]
memset(aes_key_c, 0, 16)
ctx = tp_new(c_tables, aes_key_c, 1)
if ctx == NULL:
raise MemoryError("Failed to set up toeplitz-aes kernel")
d = tp_digest64(ctx, <const uint8_t*>PyBytes_AsString(window))
tp_free(ctx)
return d
+542
View File
@@ -0,0 +1,542 @@
/* toeplitz-aes chunker scan kernel, see toeplitz_aes_impl.h and
* toeplitz_aes.pyx. Mirrors rabin_aes_impl.c / goldilocks_aes_impl.c; only
* the rolling arithmetic differs (tabulated LFSR/Toeplitz hash over
* GF(2)[x] mod a fixed public P). */
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
#include "toeplitz_aes_impl.h"
/* low 64 bits of the fixed public irreducible P = x^64 + x^4 + x^3 + x + 1
* (the x^64 coefficient is implicit). Public: the secrecy lives in T. */
#define TP_PLO 0x1BULL
struct TP_CTX {
/* rolling tables; in0_tbl is the secret random table T itself, the other
* three are algebraic combinations of T and P (see toeplitz_aes_impl.h).
* All indices used in the scan are plaintext bytes, so no load ever sits
* on the serial digest dependency chain (unlike rabin-aes, whose
* reduction lookup is indexed by digest bits). */
uint64_t in0_tbl[256]; /* T[b] (incoming byte, newest) */
uint64_t in1_tbl[256]; /* x * T[b] mod P (stride-2, older incoming) */
uint64_t out64_tbl[256]; /* x^64 * T[b] mod P (single-step removal) */
uint64_t out65_tbl[256]; /* x^65 * T[b] mod P (stride-2 removal, older) */
uint8_t rk[11][16]; /* AES-128 round keys, for the hardware paths */
EVP_CIPHER_CTX *evp;
int use_hw;
};
/* --- portable helpers ------------------------------------------------- */
static inline void store_le64(uint8_t *b, uint64_t v)
{
for (int j = 0; j < 8; j++)
b[j] = (uint8_t)(v >> (8 * j));
}
static inline uint64_t load_le64(const uint8_t *b)
{
uint64_t v = 0;
for (int j = 0; j < 8; j++)
v |= ((uint64_t)b[j]) << (8 * j);
return v;
}
/* --- GF(2)[x] mod P rolling ------------------------------------------- */
/* d * x mod P: shift out the degree-63 coefficient and, if it was set, add
* back x^64 = P_LO. Branchless: the arithmetic right shift smears the top
* bit into a full mask. */
static inline uint64_t tp_mulx(uint64_t d)
{
return (d << 1) ^ (((uint64_t)((int64_t)d >> 63)) & TP_PLO);
}
/* d * x^2 mod P: same for the top two coefficients (x^65 = x * P_LO, which
* has degree 5, so no second-order reduction is needed). */
static inline uint64_t tp_mulx2(uint64_t d)
{
return (d << 2) ^ (((uint64_t)((int64_t)d >> 63)) & (TP_PLO << 1)) ^
(((uint64_t)((int64_t)(d << 1) >> 63)) & TP_PLO);
}
/* Advance the digest by one byte. The digest is the window's Toeplitz hash
* d = sum_j x^(63-j) * T[b_j] (oldest byte at x^63, newest at x^0), so
* d' = d*x ^ x^64*T[byte_out] ^ T[byte_in] (mod P).
* Both table terms are plaintext-indexed and combine off the critical path;
* the serial chain is just tp_mulx (shift + masked XOR). */
static inline uint64_t tp_roll(const TP_CTX *c, uint64_t d, uint8_t byte_out, uint8_t byte_in)
{
return tp_mulx(d) ^ c->out64_tbl[byte_out] ^ c->in0_tbl[byte_in];
}
/* Advance the digest by TWO bytes in one step (exact composition of two
* tp_roll steps, expanded using linearity over GF(2)):
* d' = d*x^2 ^ o0*x^65*T ^ o1*x^64*T ^ i0*x*T ^ i1*T (mod P)
* where (o0, i0) belong to the first composed step and (o1, i1) to the second.
* Used to run two independent even/odd lanes; bit-identical to tp_roll twice. */
static inline uint64_t tp_roll2(const TP_CTX *c, uint64_t d,
uint8_t o0, uint8_t o1, uint8_t i0, uint8_t i1)
{
uint64_t delta = c->out65_tbl[o0] ^ c->out64_tbl[o1] ^ c->in1_tbl[i0] ^ c->in0_tbl[i1];
return tp_mulx2(d) ^ delta;
}
uint64_t tp_digest64(const TP_CTX *c, const uint8_t *q)
{
uint64_t d = 0;
for (int j = 0; j < 64; j++)
d = tp_mulx(d) ^ c->in0_tbl[q[j]];
return d;
}
/* --- AES-128 key expansion (encryption-only, standard FIPS-197) -------- */
static const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,
};
static void aes128_expand(const uint8_t key[16], uint8_t rk[11][16])
{
uint8_t rcon = 1;
memcpy(rk[0], key, 16);
for (int i = 1; i <= 10; i++) {
rk[i][0] = (uint8_t)(rk[i - 1][0] ^ sbox[rk[i - 1][13]] ^ rcon);
rk[i][1] = (uint8_t)(rk[i - 1][1] ^ sbox[rk[i - 1][14]]);
rk[i][2] = (uint8_t)(rk[i - 1][2] ^ sbox[rk[i - 1][15]]);
rk[i][3] = (uint8_t)(rk[i - 1][3] ^ sbox[rk[i - 1][12]]);
for (int j = 4; j < 16; j++)
rk[i][j] = (uint8_t)(rk[i - 1][j] ^ rk[i][j - 4]);
rcon = (uint8_t)((rcon << 1) ^ ((rcon >> 7) * 0x1b));
}
}
/* --- two-lane digest fill (shared by the portable path) ----------------- */
/* Compute the digests for positions [0, m), writing each as an AES input
* block (digest LE in bytes 0..7; bytes 8..15 must already be zero) into inb.
* On entry *d_io is the digest at position -1; on return, at position m-1.
* Uses two independent even/odd lanes advancing with stride 2 so the two
* dependency chains overlap; bit-identical to m single tp_roll steps. */
static inline void tp_fill2(const TP_CTX *c, const uint8_t *q, const uint8_t *qo,
size_t m, uint64_t *d_io, uint8_t *inb)
{
uint64_t d = *d_io, da, db;
size_t i;
if (m == 1) {
d = tp_roll(c, d, qo[0], q[0]);
store_le64(inb, d);
*d_io = d;
return;
}
db = tp_roll(c, d, qo[0], q[0]); /* even lane: d_0 */
da = tp_roll2(c, d, qo[0], qo[1], q[0], q[1]); /* odd lane: d_1 */
store_le64(inb, db);
store_le64(inb + 16, da);
for (i = 2; i + 1 < m; i += 2) {
db = tp_roll2(c, db, qo[i - 1], qo[i], q[i - 1], q[i]);
da = tp_roll2(c, da, qo[i], qo[i + 1], q[i], q[i + 1]);
store_le64(inb + i * 16, db);
store_le64(inb + (i + 1) * 16, da);
}
if (i < m) { /* odd m: one even-lane step left for position m-1 */
db = tp_roll2(c, db, qo[i - 1], qo[i], q[i - 1], q[i]);
store_le64(inb + i * 16, db);
*d_io = db;
} else {
*d_io = da;
}
}
/* --- portable path: batched AES via OpenSSL EVP ------------------------ */
/* sub-batch size (blocks); 512 * 16 B = 8 KiB in/out buffers on the stack */
#define TP_SB 512
static int64_t tp_scan_evp(TP_CTX *c, const uint8_t *p, size_t n, uint64_t *digest, uint64_t mask)
{
uint8_t inb[TP_SB * 16];
uint8_t outb[TP_SB * 16];
uint64_t d = *digest;
size_t base = 0;
int outlen;
memset(inb, 0, sizeof(inb)); /* bytes 8..15 of each block stay zero */
while (base < n) {
const uint8_t *q = p + base;
size_t m = n - base;
if (m > TP_SB)
m = TP_SB;
tp_fill2(c, q, q - 64, m, &d, inb);
outlen = 0;
if (!EVP_EncryptUpdate(c->evp, outb, &outlen, inb, (int)(m * 16)) || outlen != (int)(m * 16))
return -2; /* OpenSSL failure; caller raises */
for (size_t i = 0; i < m; i++) {
if ((load_le64(outb + i * 16) & mask) == 0) {
/* recompute the digest at the cut position (cheaper than
* storing all digests: one 64-byte warm-up per chunk) */
*digest = tp_digest64(c, q + i - 63);
return (int64_t)(base + i);
}
}
base += m;
}
*digest = d;
return -1;
}
/* --- hardware paths ----------------------------------------------------
*
* Both hardware paths process groups of 8 positions: the two rolling lanes
* advance 4 stride-2 steps each (two independent shift/xor chains), then the
* 8 digests are encrypted with interleaved AES instructions, which execute
* on different ports than the table loads and thus overlap with the next
* group's rolling work.
*
* Loop invariant: entering a group at position i, db = digest at i and
* da = digest at i+1 (both already computed); the group emits digests for
* i..i+7 and prepares db/da for i+8/i+9 (reading bytes up to q[i+9], hence
* the i + 10 <= n loop bound). */
#if defined(__aarch64__) && defined(__ARM_FEATURE_AES)
#define TP_HAVE_HW 1
#define TP_KIND_HW "aes-arm64"
#include <arm_neon.h>
#if defined(__linux__)
#include <sys/auxv.h>
#ifndef HWCAP_AES
#define HWCAP_AES (1 << 3)
#endif
#endif
static int tp_hw_available(void)
{
#if defined(__linux__)
return (getauxval(AT_HWCAP) & HWCAP_AES) != 0;
#else
return 1; /* compiler targeted +aes; on Apple Silicon it is always there */
#endif
}
static inline uint8x16_t tp_aes1_neon(const uint8x16_t k[11], uint8x16_t b)
{
for (int r = 0; r < 9; r++)
b = vaesmcq_u8(vaeseq_u8(b, k[r]));
return veorq_u8(vaeseq_u8(b, k[9]), k[10]);
}
static int64_t tp_scan_hw(TP_CTX *c, const uint8_t *p, size_t n, uint64_t *digest, uint64_t mask)
{
uint8x16_t k[11];
uint64_t d = *digest, da, db;
const uint8_t *qo = p - 64;
size_t i = 0;
int lanes_live = 0;
for (int j = 0; j < 11; j++)
k[j] = vld1q_u8(c->rk[j]);
if (n >= 10) {
db = tp_roll(c, d, qo[0], p[0]); /* d_0 */
da = tp_roll2(c, d, qo[0], qo[1], p[0], p[1]); /* d_1 */
lanes_live = 1;
}
while (lanes_live && i + 10 <= n) {
uint64_t dg[8];
uint8x16_t b0, b1, b2, b3, b4, b5, b6, b7;
dg[0] = db;
dg[1] = da;
db = tp_roll2(c, db, qo[i + 1], qo[i + 2], p[i + 1], p[i + 2]); dg[2] = db;
da = tp_roll2(c, da, qo[i + 2], qo[i + 3], p[i + 2], p[i + 3]); dg[3] = da;
db = tp_roll2(c, db, qo[i + 3], qo[i + 4], p[i + 3], p[i + 4]); dg[4] = db;
da = tp_roll2(c, da, qo[i + 4], qo[i + 5], p[i + 4], p[i + 5]); dg[5] = da;
db = tp_roll2(c, db, qo[i + 5], qo[i + 6], p[i + 5], p[i + 6]); dg[6] = db;
da = tp_roll2(c, da, qo[i + 6], qo[i + 7], p[i + 6], p[i + 7]); dg[7] = da;
/* prepare the next group's invariant (digests at i+8, i+9) */
db = tp_roll2(c, db, qo[i + 7], qo[i + 8], p[i + 7], p[i + 8]);
da = tp_roll2(c, da, qo[i + 8], qo[i + 9], p[i + 8], p[i + 9]);
b0 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[0]), vcreate_u64(0)));
b1 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[1]), vcreate_u64(0)));
b2 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[2]), vcreate_u64(0)));
b3 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[3]), vcreate_u64(0)));
b4 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[4]), vcreate_u64(0)));
b5 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[5]), vcreate_u64(0)));
b6 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[6]), vcreate_u64(0)));
b7 = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(dg[7]), vcreate_u64(0)));
for (int r = 0; r < 9; r++) {
b0 = vaesmcq_u8(vaeseq_u8(b0, k[r]));
b1 = vaesmcq_u8(vaeseq_u8(b1, k[r]));
b2 = vaesmcq_u8(vaeseq_u8(b2, k[r]));
b3 = vaesmcq_u8(vaeseq_u8(b3, k[r]));
b4 = vaesmcq_u8(vaeseq_u8(b4, k[r]));
b5 = vaesmcq_u8(vaeseq_u8(b5, k[r]));
b6 = vaesmcq_u8(vaeseq_u8(b6, k[r]));
b7 = vaesmcq_u8(vaeseq_u8(b7, k[r]));
}
b0 = veorq_u8(vaeseq_u8(b0, k[9]), k[10]);
b1 = veorq_u8(vaeseq_u8(b1, k[9]), k[10]);
b2 = veorq_u8(vaeseq_u8(b2, k[9]), k[10]);
b3 = veorq_u8(vaeseq_u8(b3, k[9]), k[10]);
b4 = veorq_u8(vaeseq_u8(b4, k[9]), k[10]);
b5 = veorq_u8(vaeseq_u8(b5, k[9]), k[10]);
b6 = veorq_u8(vaeseq_u8(b6, k[9]), k[10]);
b7 = veorq_u8(vaeseq_u8(b7, k[9]), k[10]);
{
uint64_t c0 = vgetq_lane_u64(vreinterpretq_u64_u8(b0), 0);
uint64_t c1 = vgetq_lane_u64(vreinterpretq_u64_u8(b1), 0);
uint64_t c2 = vgetq_lane_u64(vreinterpretq_u64_u8(b2), 0);
uint64_t c3 = vgetq_lane_u64(vreinterpretq_u64_u8(b3), 0);
uint64_t c4 = vgetq_lane_u64(vreinterpretq_u64_u8(b4), 0);
uint64_t c5 = vgetq_lane_u64(vreinterpretq_u64_u8(b5), 0);
uint64_t c6 = vgetq_lane_u64(vreinterpretq_u64_u8(b6), 0);
uint64_t c7 = vgetq_lane_u64(vreinterpretq_u64_u8(b7), 0);
if ((((c0 & mask) == 0) | ((c1 & mask) == 0) | ((c2 & mask) == 0) | ((c3 & mask) == 0) |
((c4 & mask) == 0) | ((c5 & mask) == 0) | ((c6 & mask) == 0) | ((c7 & mask) == 0))) {
uint64_t cs[8] = {c0, c1, c2, c3, c4, c5, c6, c7};
for (int j = 0; j < 8; j++) {
if ((cs[j] & mask) == 0) {
*digest = dg[j];
return (int64_t)(i + j);
}
}
}
}
i += 8;
}
/* tail: single positions. If the lanes ran, db already is the digest at
* position i; check it first, then continue with single-step rolls. */
if (lanes_live && i < n) {
uint8x16_t b = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(db), vcreate_u64(0)));
b = tp_aes1_neon(k, b);
if ((vgetq_lane_u64(vreinterpretq_u64_u8(b), 0) & mask) == 0) {
*digest = db;
return (int64_t)i;
}
d = db;
i++;
}
while (i < n) {
uint8x16_t b;
d = tp_roll(c, d, qo[i], p[i]);
b = vreinterpretq_u8_u64(vcombine_u64(vcreate_u64(d), vcreate_u64(0)));
b = tp_aes1_neon(k, b);
if ((vgetq_lane_u64(vreinterpretq_u64_u8(b), 0) & mask) == 0) {
*digest = d;
return (int64_t)i;
}
i++;
}
*digest = d;
return -1;
}
#elif (defined(__x86_64__) || defined(_M_X64)) && (defined(__GNUC__) || defined(__clang__))
#define TP_HAVE_HW 1
#define TP_KIND_HW "aes-ni"
#include <immintrin.h>
static int tp_hw_available(void)
{
return __builtin_cpu_supports("aes") && __builtin_cpu_supports("sse2");
}
__attribute__((target("aes,sse2"))) static inline __m128i
tp_aes1_ni(const uint8_t rk[11][16], __m128i b)
{
b = _mm_xor_si128(b, _mm_loadu_si128((const __m128i *)rk[0]));
for (int r = 1; r < 10; r++)
b = _mm_aesenc_si128(b, _mm_loadu_si128((const __m128i *)rk[r]));
return _mm_aesenclast_si128(b, _mm_loadu_si128((const __m128i *)rk[10]));
}
__attribute__((target("aes,sse2"))) static int64_t
tp_scan_hw(TP_CTX *c, const uint8_t *p, size_t n, uint64_t *digest, uint64_t mask)
{
uint64_t d = *digest, da, db;
const uint8_t *qo = p - 64;
size_t i = 0;
int lanes_live = 0;
if (n >= 10) {
db = tp_roll(c, d, qo[0], p[0]); /* d_0 */
da = tp_roll2(c, d, qo[0], qo[1], p[0], p[1]); /* d_1 */
lanes_live = 1;
}
while (lanes_live && i + 10 <= n) {
uint64_t dg[8];
__m128i b0, b1, b2, b3, b4, b5, b6, b7, kr;
dg[0] = db;
dg[1] = da;
db = tp_roll2(c, db, qo[i + 1], qo[i + 2], p[i + 1], p[i + 2]); dg[2] = db;
da = tp_roll2(c, da, qo[i + 2], qo[i + 3], p[i + 2], p[i + 3]); dg[3] = da;
db = tp_roll2(c, db, qo[i + 3], qo[i + 4], p[i + 3], p[i + 4]); dg[4] = db;
da = tp_roll2(c, da, qo[i + 4], qo[i + 5], p[i + 4], p[i + 5]); dg[5] = da;
db = tp_roll2(c, db, qo[i + 5], qo[i + 6], p[i + 5], p[i + 6]); dg[6] = db;
da = tp_roll2(c, da, qo[i + 6], qo[i + 7], p[i + 6], p[i + 7]); dg[7] = da;
db = tp_roll2(c, db, qo[i + 7], qo[i + 8], p[i + 7], p[i + 8]);
da = tp_roll2(c, da, qo[i + 8], qo[i + 9], p[i + 8], p[i + 9]);
/* 8 blocks in flight; round keys are re-loaded per round (they stay
* hot in L1) to keep register pressure within the 16 XMM registers */
kr = _mm_loadu_si128((const __m128i *)c->rk[0]);
b0 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[0]), kr);
b1 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[1]), kr);
b2 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[2]), kr);
b3 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[3]), kr);
b4 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[4]), kr);
b5 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[5]), kr);
b6 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[6]), kr);
b7 = _mm_xor_si128(_mm_set_epi64x(0, (long long)dg[7]), kr);
for (int r = 1; r < 10; r++) {
kr = _mm_loadu_si128((const __m128i *)c->rk[r]);
b0 = _mm_aesenc_si128(b0, kr);
b1 = _mm_aesenc_si128(b1, kr);
b2 = _mm_aesenc_si128(b2, kr);
b3 = _mm_aesenc_si128(b3, kr);
b4 = _mm_aesenc_si128(b4, kr);
b5 = _mm_aesenc_si128(b5, kr);
b6 = _mm_aesenc_si128(b6, kr);
b7 = _mm_aesenc_si128(b7, kr);
}
kr = _mm_loadu_si128((const __m128i *)c->rk[10]);
b0 = _mm_aesenclast_si128(b0, kr);
b1 = _mm_aesenclast_si128(b1, kr);
b2 = _mm_aesenclast_si128(b2, kr);
b3 = _mm_aesenclast_si128(b3, kr);
b4 = _mm_aesenclast_si128(b4, kr);
b5 = _mm_aesenclast_si128(b5, kr);
b6 = _mm_aesenclast_si128(b6, kr);
b7 = _mm_aesenclast_si128(b7, kr);
{
uint64_t cs[8];
cs[0] = (uint64_t)_mm_cvtsi128_si64(b0);
cs[1] = (uint64_t)_mm_cvtsi128_si64(b1);
cs[2] = (uint64_t)_mm_cvtsi128_si64(b2);
cs[3] = (uint64_t)_mm_cvtsi128_si64(b3);
cs[4] = (uint64_t)_mm_cvtsi128_si64(b4);
cs[5] = (uint64_t)_mm_cvtsi128_si64(b5);
cs[6] = (uint64_t)_mm_cvtsi128_si64(b6);
cs[7] = (uint64_t)_mm_cvtsi128_si64(b7);
for (int j = 0; j < 8; j++) {
if ((cs[j] & mask) == 0) {
*digest = dg[j];
return (int64_t)(i + j);
}
}
}
i += 8;
}
if (lanes_live && i < n) {
__m128i b = tp_aes1_ni(c->rk, _mm_set_epi64x(0, (long long)db));
if (((uint64_t)_mm_cvtsi128_si64(b) & mask) == 0) {
*digest = db;
return (int64_t)i;
}
d = db;
i++;
}
while (i < n) {
__m128i b;
d = tp_roll(c, d, qo[i], p[i]);
b = tp_aes1_ni(c->rk, _mm_set_epi64x(0, (long long)d));
if (((uint64_t)_mm_cvtsi128_si64(b) & mask) == 0) {
*digest = d;
return (int64_t)i;
}
i++;
}
*digest = d;
return -1;
}
#else
#define TP_HAVE_HW 0
#endif
/* --- context management and dispatch ----------------------------------- */
TP_CTX *tp_new(const uint64_t tables[TP_TABLES * 256], const uint8_t aes_key[16], int force_sw)
{
TP_CTX *c = calloc(1, sizeof(TP_CTX));
if (c == NULL)
return NULL;
memcpy(c->in0_tbl, tables + 0 * 256, sizeof(c->in0_tbl));
memcpy(c->in1_tbl, tables + 1 * 256, sizeof(c->in1_tbl));
memcpy(c->out64_tbl, tables + 2 * 256, sizeof(c->out64_tbl));
memcpy(c->out65_tbl, tables + 3 * 256, sizeof(c->out65_tbl));
aes128_expand(aes_key, c->rk);
#if TP_HAVE_HW
c->use_hw = !force_sw && tp_hw_available();
#else
(void)force_sw;
c->use_hw = 0;
#endif
c->evp = EVP_CIPHER_CTX_new();
if (c->evp == NULL) {
free(c);
return NULL;
}
if (!EVP_EncryptInit_ex(c->evp, EVP_aes_128_ecb(), NULL, aes_key, NULL) ||
!EVP_CIPHER_CTX_set_padding(c->evp, 0)) {
EVP_CIPHER_CTX_free(c->evp);
free(c);
return NULL;
}
return c;
}
void tp_free(TP_CTX *c)
{
if (c != NULL) {
if (c->evp != NULL)
EVP_CIPHER_CTX_free(c->evp);
free(c);
}
}
const char *tp_kind(const TP_CTX *c)
{
#if TP_HAVE_HW
if (c->use_hw)
return TP_KIND_HW;
#endif
(void)c;
return "evp";
}
int64_t tp_scan(TP_CTX *c, const uint8_t *p, size_t n, uint64_t *digest, uint64_t mask)
{
#if TP_HAVE_HW
if (c->use_hw)
return tp_scan_hw(c, p, n, digest, mask);
#endif
return tp_scan_evp(c, p, n, digest, mask);
}
+62
View File
@@ -0,0 +1,62 @@
/* toeplitz-aes chunker scan kernel: rolling LFSR-based Toeplitz hash with a
* secret byte table (UHF) + AES-128 (PRF).
*
* See toeplitz_aes.pyx for the scheme description. This C layer only
* implements the performance-critical inner scan: roll the tabulated
* Toeplitz digest one byte at a time, encrypt each digest with AES-128, and
* cut where the ciphertext's low bits (little-endian uint64 of the first 8
* ciphertext bytes) are all zero under mask.
*
* Structure is identical to rabin_aes_impl.c / goldilocks_aes_impl.c (two
* even/odd rolling lanes, groups of 8 pipelined AES blocks on the hardware
* paths, batched OpenSSL EVP on the portable path); only the rolling
* arithmetic differs: the digest is sum_j x^(63-j) * T[b_j] over
* GF(2)[x] mod P, with a FIXED PUBLIC irreducible P of degree 64
* (x^64 + x^4 + x^3 + x + 1, top bit implicit) and a secret random table T
* of 256 uint64 values. The per-step "multiply by x" is a shift plus a
* branchless conditional XOR of P's low bits, and both table lookups are
* indexed by plaintext bytes, so the serial dependency chain contains no
* loads at all. */
#ifndef BORG_TOEPLITZ_AES_IMPL_H
#define BORG_TOEPLITZ_AES_IMPL_H
#include <stdint.h>
#include <stddef.h>
typedef struct TP_CTX TP_CTX;
/* Number of 256-entry rolling tables passed to tp_new, in this order:
* [0] in0_tbl: T[b] (the secret table; incoming byte, newest)
* [1] in1_tbl: x * T[b] mod P (stride-2, older incoming byte)
* [2] out64_tbl: x^64 * T[b] mod P (removal of the leaving byte)
* [3] out65_tbl: x^65 * T[b] mod P (stride-2 removal, older leaving byte)
* Tables 1..3 are algebraic combinations of table 0 and P; they enable the
* two-lane (even/odd position) rolling that shortens the dependency chain. */
#define TP_TABLES 4
/* Create a kernel context.
* tables: TP_TABLES * 256 uint64 entries, see above.
* aes_key: 16 bytes (AES-128).
* force_sw: nonzero forces the portable OpenSSL path (for tests/benchmarks).
* Returns NULL on allocation/OpenSSL failure. */
TP_CTX *tp_new(const uint64_t *tables, const uint8_t aes_key[16], int force_sw);
void tp_free(TP_CTX *ctx);
/* Which path this context uses: "aes-arm64", "aes-ni" or "evp". */
const char *tp_kind(const TP_CTX *ctx);
/* Full (non-rolling) digest of the 64 bytes at q: the window warm-up at the
* start of each chunk scan. */
uint64_t tp_digest64(const TP_CTX *ctx, const uint8_t *q);
/* Scan up to n positions. For position i (0 <= i < n), the rolling digest is
* advanced to cover the 64-byte window p[i-63..i] (reading the outgoing byte
* at p[i-64], which the caller must guarantee to be accessible) and the cut
* condition is evaluated on AES(digest).
* Returns the first i whose cut condition matched (digest is left at position i),
* or -1 if none matched (digest is left at position n-1). */
int64_t tp_scan(TP_CTX *ctx, const uint8_t *p, size_t n, uint64_t *digest, uint64_t mask);
#endif
+3
View File
@@ -135,6 +135,7 @@ CH_BUZHASH64 = "buzhash64"
CH_FASTCDC = "fastcdc"
CH_RABIN_AES = "rabin-aes"
CH_GOLDILOCKS_AES = "goldilocks-aes"
CH_TOEPLITZ_AES = "toeplitz-aes"
CH_FIXED = "fixed"
CH_FAIL = "fail"
@@ -156,6 +157,8 @@ BUZHASH64_PARAMS = (CH_BUZHASH64, CHUNK_MIN_EXP, CHUNK_MAX_EXP, HASH_MASK_BITS,
RABIN_AES_PARAMS = (CH_RABIN_AES, CHUNK_MIN_EXP, CHUNK_MAX_EXP, HASH_MASK_BITS, NC_LEVEL)
# goldilocks-aes: same param shape as rabin-aes (fixed 64-byte window).
GOLDILOCKS_AES_PARAMS = (CH_GOLDILOCKS_AES, CHUNK_MIN_EXP, CHUNK_MAX_EXP, HASH_MASK_BITS, NC_LEVEL)
# toeplitz-aes: same param shape as rabin-aes (fixed 64-byte window).
TOEPLITZ_AES_PARAMS = (CH_TOEPLITZ_AES, CHUNK_MIN_EXP, CHUNK_MAX_EXP, HASH_MASK_BITS, NC_LEVEL)
CHUNKER_PARAMS = FASTCDC_PARAMS # the default chunker for file content data
# chunker params for the items metadata stream, finer granularity
+1 -1
View File
@@ -358,7 +358,7 @@ def ChunkerParams(s):
"required: 0 <= nc_level and 1 <= chunk_mask - nc_level and chunk_mask + nc_level <= 48"
)
return CH_FASTCDC, chunk_min, chunk_max, chunk_mask, nc_level
if algo in (CH_RABIN_AES, CH_GOLDILOCKS_AES):
if algo in (CH_RABIN_AES, CH_GOLDILOCKS_AES, CH_TOEPLITZ_AES):
# <algo>, chunk_min, chunk_max, chunk_mask, nc_level
# both UHF-then-PRF chunkers have a fixed 64-byte window, so there is no window_size field.
# nc_level is required; use nc_level 0 to disable normalized chunking.
@@ -0,0 +1,238 @@
from hashlib import sha256
from io import BytesIO
import os
import random
import pytest
from . import cf, cf_expand
from ...chunkers import ChunkerToeplitzAES, get_chunker
from ...chunkers.rabin_aes import _is_irreducible
from ...chunkers.toeplitz_aes import toeplitz_aes_digest64, toeplitz_aes_get_table, toeplitz_aes_get_tables, _P
from ...constants import * # NOQA
from ...helpers import hex_to_bin
# from os.urandom(32)
key0 = hex_to_bin("ad9f89095817f0566337dc9ee292fcd59b70f054a8200151f1df5f21704824da")
key1 = hex_to_bin("f1088c7e9e6ae83557ad1558ff36c44a369ea719d1081c29684f52ffccb72cb8")
def H(data):
return sha256(data).digest()
def test_chunkpoints_toeplitz_aes_unchanged():
def twist(size):
x = 1
a = bytearray(size)
for i in range(size):
x = (x * 1103515245 + 12345) & 0x7FFFFFFF
a[i] = x & 0xFF
return a
data = twist(100000)
runs = []
for nc_level in (0, 2, 3):
for minexp in (6, 7, 11, 12): # toeplitz-aes requires minexp >= 6 (64-byte window)
for maxexp in (15, 17):
if minexp >= maxexp:
continue
for maskbits in (7, 10, 12):
if maskbits - nc_level < 1: # nc_level needs room below the base mask bits
continue
for key in (key0, key1):
fh = BytesIO(data)
chunker = ChunkerToeplitzAES(key, minexp, maxexp, maskbits, nc_level)
chunks = [H(c) for c in cf(chunker.chunkify(fh, -1))]
runs.append(H(b"".join(chunks)))
# The "correct" hash below matches the existing chunker behavior.
# Future chunker optimizations must not change this, or existing repos will bloat.
overall_hash = H(b"".join(runs))
print(overall_hash.hex())
assert overall_hash == hex_to_bin("d98a97b3eeb1122c602c4581f486bd356404447efe2bf550000e12d1f046b843")
def test_toeplitz_aes_kernels_identical():
# the OpenSSL EVP batch path and the AES hardware instruction path (if available
# on this platform) must produce identical cut points.
data = os.urandom(4 * 1024 * 1024)
def sizes(chunker):
return [c.meta["size"] for c in chunker.chunkify(BytesIO(data))]
default = ChunkerToeplitzAES(key0, 10, 16, 14, 2)
sizes_default = sizes(default)
os.environ["BORG_TOEPLITZ_AES_FORCE_EVP"] = "1"
try:
forced = ChunkerToeplitzAES(key0, 10, 16, 14, 2)
assert forced.kernel == "evp"
sizes_evp = sizes(forced)
finally:
del os.environ["BORG_TOEPLITZ_AES_FORCE_EVP"]
assert sizes_default == sizes_evp
# whatever kernel was selected by default, it must be a known one
assert default.kernel in ("aes-arm64", "aes-ni", "evp")
def test_toeplitz_aes_chunksize_distribution():
data = os.urandom(1048576)
min_exp, max_exp, mask, nc_level = 10, 16, 14, 2 # chunk size target 16 KiB, clip at 1 KiB and 64 KiB
chunker = ChunkerToeplitzAES(key0, min_exp, max_exp, mask, nc_level)
f = BytesIO(data)
chunks = cf(chunker.chunkify(f))
del chunks[-1] # get rid of the last chunk, it can be smaller than 2**min_exp
chunk_sizes = [len(chunk) for chunk in chunks]
chunks_count = len(chunks)
min_chunksize_observed = min(chunk_sizes)
max_chunksize_observed = max(chunk_sizes)
min_count = sum(int(size == 2**min_exp) for size in chunk_sizes)
max_count = sum(int(size == 2**max_exp) for size in chunk_sizes)
print(
f"count: {chunks_count} min: {min_chunksize_observed} max: {max_chunksize_observed} "
f"min count: {min_count} max count: {max_count}"
)
# usually there will about 64 chunks
assert 32 < chunks_count < 128
# chunks always must be between min and max (clipping must work):
assert min_chunksize_observed >= 2**min_exp
assert max_chunksize_observed <= 2**max_exp
# most chunks should be cut due to the hash triggering, not due to clipping at min/max size:
assert min_count < 10
assert max_count < 10
def test_toeplitz_aes_shift_resilience():
# content-defined cuts must survive a prefix insertion (this also validates that the
# rolling digest update and the per-chunk window warm-up agree with each other).
data = os.urandom(4 * 1024 * 1024)
def chunk_hashes(data):
chunker = ChunkerToeplitzAES(key0, 10, 16, 14, 2)
return [H(c) for c in cf_expand(chunker.chunkify(BytesIO(data)))]
h1 = set(chunk_hashes(data))
h2 = chunk_hashes(b"PREFIX_SHIFTS_EVERYTHING" + data)
survived = sum(1 for h in h2 if h in h1)
assert survived / len(h2) > 0.9
def test_toeplitz_aes_polynomial_and_tables():
# the fixed public polynomial must be irreducible of degree 64
# (reuse the rabin-aes Rabin test, which handles degree 64)
assert _P.bit_length() - 1 == 64
assert _is_irreducible(_P)
# T must be deterministically derived from the key
t0 = toeplitz_aes_get_table(key0)
assert len(t0) == 256
assert all(0 <= v < 2**64 for v in t0)
assert t0 == toeplitz_aes_get_table(key0)
t1 = toeplitz_aes_get_table(key1)
assert t0 != t1
def pmod(v):
while v.bit_length() - 1 >= 64:
v ^= _P << (v.bit_length() - 1 - 64)
return v
in0_tbl, in1_tbl, out64_tbl, out65_tbl = toeplitz_aes_get_tables(key0)
assert in0_tbl == t0
assert len(in1_tbl) == len(out64_tbl) == len(out65_tbl) == 256
# spot check the derived tables against T and P
for b in (0, 1, 2, 128, 255):
assert in1_tbl[b] == pmod(t0[b] << 1)
assert out64_tbl[b] == pmod(t0[b] << 64)
assert out65_tbl[b] == pmod(t0[b] << 65)
def test_toeplitz_aes_digest_reference():
# the C kernel's digest must match a pure-Python big-int reference:
# digest = sum_j x^(63-j) * T[b_j] mod P
def pmod(v):
while v.bit_length() - 1 >= 64:
v ^= _P << (v.bit_length() - 1 - 64)
return v
rng = random.Random(42)
for key in (key0, key1):
t = toeplitz_aes_get_table(key)
windows = [b"\x00" * 64, b"\xff" * 64, b"\x42" * 64, bytes(range(64))]
windows += [rng.randbytes(64) for _ in range(10)]
for w in windows:
ref = 0
for b in w:
ref = pmod(ref << 1) ^ t[b]
assert toeplitz_aes_digest64(key, w) == ref
def test_toeplitz_aes_get_chunker():
# without a key, get_chunker uses an all-zero key; chunking must still work and be deterministic
data = os.urandom(2 * 1024 * 1024)
a = cf_expand(get_chunker(*TOEPLITZ_AES_PARAMS, key=None).chunkify(BytesIO(data)))
b = cf_expand(get_chunker("toeplitz-aes", 19, 23, 21, 2, key=None).chunkify(BytesIO(data)))
assert a == b
assert b"".join(a) == data
def test_toeplitz_aes_params_parsing():
from argparse import ArgumentTypeError
from ...helpers import ChunkerParams
# toeplitz-aes, chunk_min, chunk_max, chunk_mask, nc_level (no window field)
assert ChunkerParams("toeplitz-aes,19,23,21,2") == (CH_TOEPLITZ_AES, 19, 23, 21, 2)
assert ChunkerParams("toeplitz-aes,10,23,16,0") == (CH_TOEPLITZ_AES, 10, 23, 16, 0)
# a 6-field (buzhash64-style, with window) toeplitz-aes must be rejected
with pytest.raises(ArgumentTypeError):
ChunkerParams("toeplitz-aes,19,23,21,4095,2")
# a 4-field toeplitz-aes (missing nc_level) must be rejected, not fall into old-style compat mode
with pytest.raises(ArgumentTypeError):
ChunkerParams("toeplitz-aes,19,23,21")
# nc_level out of range (chunk_mask - nc_level < 1)
with pytest.raises(ArgumentTypeError):
ChunkerParams("toeplitz-aes,19,23,21,21")
# chunk_min <= chunk_mask <= chunk_max violated
with pytest.raises(ArgumentTypeError):
ChunkerParams("toeplitz-aes,19,23,24,2")
# chunk_min < 6 is not allowed (the 64-byte window needs 64 bytes of in-chunk history)
with pytest.raises(ArgumentTypeError):
ChunkerParams("toeplitz-aes,5,23,21,2")
@pytest.mark.skipif("BORG_TESTS_SLOW" not in os.environ, reason="slow tests not enabled, use BORG_TESTS_SLOW=1")
@pytest.mark.parametrize("worker", range(os.cpu_count() or 1))
def test_fuzz_toeplitz_aes(worker):
# Fuzz toeplitz-aes with random and uniform data of misc. sizes and misc keys.
def rnd_key():
return os.urandom(32)
# decompose TOEPLITZ_AES_PARAMS = (algo, min_exp, max_exp, mask_bits, nc_level)
algo, min_exp, max_exp, mask_bits, nc_level = TOEPLITZ_AES_PARAMS
assert algo == CH_TOEPLITZ_AES
keys = [b"\0" * 32] + [rnd_key() for _ in range(10)]
sizes = [random.randint(1, 4 * 1024 * 1024) for _ in range(50)]
for key in keys:
chunker = ChunkerToeplitzAES(key, min_exp, max_exp, mask_bits, nc_level)
for size in sizes:
# Random data
data = os.urandom(size)
with BytesIO(data) as bio:
parts = cf_expand(chunker.chunkify(bio))
assert b"".join(parts) == data
# All-same data (non-zero)
data = b"\x42" * size
with BytesIO(data) as bio:
parts = cf_expand(chunker.chunkify(bio))
assert b"".join(parts) == data
# All-zero data
data = b"\x00" * size
with BytesIO(data) as bio:
parts = cf_expand(chunker.chunkify(bio))
assert b"".join(parts) == data