floorvault 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- floorvault/__init__.py +100 -0
- floorvault/beacons.py +406 -0
- floorvault/core.py +771 -0
- floorvault/inspector.py +95 -0
- floorvault/key_recovery.py +95 -0
- floorvault/keyring.py +171 -0
- floorvault/libsql_adapter.py +83 -0
- floorvault/memory.py +304 -0
- floorvault/migration.py +357 -0
- floorvault/platform_support.py +244 -0
- floorvault/providers/__init__.py +17 -0
- floorvault/providers/adaptive.py +281 -0
- floorvault/providers/base.py +42 -0
- floorvault/providers/generation_store.py +413 -0
- floorvault/providers/linux_keyring.py +301 -0
- floorvault/providers/platform_custody.py +429 -0
- floorvault/providers/vault_transit.py +559 -0
- floorvault/providers/windows_dpapi.py +249 -0
- floorvault/records.py +209 -0
- floorvault/sqlalchemy_adapter.py +489 -0
- floorvault/sqlite_adapter.py +324 -0
- floorvault/sqlite_migration.py +241 -0
- floorvault/vault_rotation.py +115 -0
- floorvault/vaultkit/__init__.py +28 -0
- floorvault/vaultkit/session_crypto.py +112 -0
- floorvault/vaultkit/vault.py +1014 -0
- floorvault-0.1.0.dist-info/METADATA +465 -0
- floorvault-0.1.0.dist-info/RECORD +33 -0
- floorvault-0.1.0.dist-info/WHEEL +4 -0
- floorvault-0.1.0.dist-info/entry_points.txt +2 -0
- floorvault-0.1.0.dist-info/licenses/LICENSE +15 -0
- floorvault-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- floorvault-0.1.0.dist-info/licenses/LICENSE-MIT +19 -0
floorvault/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""floorvault: Contextual, misuse-resistant database encryption for SQLite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .core import (
|
|
6
|
+
AppStateCrypto,
|
|
7
|
+
AppStateCryptoError,
|
|
8
|
+
DecryptionVerificationError,
|
|
9
|
+
FloorVault,
|
|
10
|
+
FloorVaultError,
|
|
11
|
+
NonceReuseError,
|
|
12
|
+
associated_data,
|
|
13
|
+
)
|
|
14
|
+
from .key_recovery import recover_master_key, wrap_master_key
|
|
15
|
+
from .keyring import KeyRing, UnknownKeyIdError
|
|
16
|
+
from .libsql_adapter import EncryptedLibSqlTable
|
|
17
|
+
from .memory import (
|
|
18
|
+
HardenedMemoryKey,
|
|
19
|
+
SecurityHardeningError,
|
|
20
|
+
disable_core_dumps,
|
|
21
|
+
)
|
|
22
|
+
from .migration import LegacyRetiredError, LegacyVaultError, MigratingVaultStore
|
|
23
|
+
from .providers.adaptive import AdaptiveKeyProvider
|
|
24
|
+
from .providers.base import KeyProvider, KeyProviderError, MissingKeyError
|
|
25
|
+
from .records import EncryptedWriteError, RecordBinding, UnsupportedWriteError
|
|
26
|
+
from .sqlite_adapter import ContextualSQLite, ContextualTable, EncryptedSQLiteTable
|
|
27
|
+
from .sqlite_migration import (
|
|
28
|
+
drop_plaintext_column,
|
|
29
|
+
migrate_plaintext_column,
|
|
30
|
+
verify_encrypted_column,
|
|
31
|
+
)
|
|
32
|
+
from .vault_rotation import rotate_vault_store
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
# SQLAlchemy is an optional extra; the adapter classes that import it resolve
|
|
37
|
+
# lazily so that `import floorvault` never hard-requires SQLAlchemy. The error
|
|
38
|
+
# types live in the driver-free records layer and are eager exports.
|
|
39
|
+
_LAZY = {
|
|
40
|
+
"SqlAlchemyEncryption",
|
|
41
|
+
"EncryptedField",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __getattr__(name: str):
|
|
46
|
+
if name in _LAZY:
|
|
47
|
+
from . import sqlalchemy_adapter
|
|
48
|
+
|
|
49
|
+
return getattr(sqlalchemy_adapter, name)
|
|
50
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# Core Cryptography
|
|
55
|
+
"FloorVault",
|
|
56
|
+
"FloorVaultError",
|
|
57
|
+
"AppStateCrypto",
|
|
58
|
+
"AppStateCryptoError",
|
|
59
|
+
"DecryptionVerificationError",
|
|
60
|
+
"NonceReuseError",
|
|
61
|
+
"associated_data",
|
|
62
|
+
# Hardware Memory Custody
|
|
63
|
+
"HardenedMemoryKey",
|
|
64
|
+
"SecurityHardeningError",
|
|
65
|
+
"disable_core_dumps",
|
|
66
|
+
# SQLite Helpers
|
|
67
|
+
"ContextualSQLite",
|
|
68
|
+
"ContextualTable",
|
|
69
|
+
"EncryptedSQLiteTable",
|
|
70
|
+
# libSQL adapter (driver-free module; the 'libsql' extra supplies the client)
|
|
71
|
+
"EncryptedLibSqlTable",
|
|
72
|
+
"migrate_plaintext_column",
|
|
73
|
+
"verify_encrypted_column",
|
|
74
|
+
"drop_plaintext_column",
|
|
75
|
+
"rotate_vault_store",
|
|
76
|
+
# Key Providers
|
|
77
|
+
"KeyProvider",
|
|
78
|
+
"KeyProviderError",
|
|
79
|
+
"MissingKeyError",
|
|
80
|
+
"AdaptiveKeyProvider",
|
|
81
|
+
# Lazy Migration
|
|
82
|
+
"MigratingVaultStore",
|
|
83
|
+
"LegacyVaultError",
|
|
84
|
+
# Raised when a migrated legacy id is asked for and its modern record is
|
|
85
|
+
# gone. Named in SECURITY.md as the read-failure type callers should handle,
|
|
86
|
+
# so it belongs in the public surface rather than only in floorvault.migration.
|
|
87
|
+
"LegacyRetiredError",
|
|
88
|
+
# Multi-generation reads (rotation)
|
|
89
|
+
"KeyRing",
|
|
90
|
+
"UnknownKeyIdError",
|
|
91
|
+
"wrap_master_key",
|
|
92
|
+
"recover_master_key",
|
|
93
|
+
# Shared record layer + adapter error types
|
|
94
|
+
"RecordBinding",
|
|
95
|
+
"EncryptedWriteError",
|
|
96
|
+
"UnsupportedWriteError",
|
|
97
|
+
# SQLAlchemy adapter (lazy; requires the 'sqlalchemy' extra)
|
|
98
|
+
"SqlAlchemyEncryption",
|
|
99
|
+
"EncryptedField",
|
|
100
|
+
]
|
floorvault/beacons.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Opt-in truncated search beacons: equality narrowing over encrypted fields.
|
|
2
|
+
|
|
3
|
+
**This module is a deliberate, measured confidentiality trade. Read this before
|
|
4
|
+
using it, and see ``SECURITY.md`` §5 for the threat model it modifies.**
|
|
5
|
+
|
|
6
|
+
A beacon is a deterministic, keyed function of a plaintext value. Storing one
|
|
7
|
+
beside a ciphertext therefore discloses information that omitting it would not:
|
|
8
|
+
|
|
9
|
+
* **Equality, coarsely.** Two rows hold the same value only if their beacons are
|
|
10
|
+
equal — collisions go one way only. Unequal beacons prove unequal values.
|
|
11
|
+
* **Bucket frequency.** The stored index is truncated to ``ceil(bits / 8)``
|
|
12
|
+
bytes, so at most ``256 ** ceil(bits / 8)`` distinct buckets exist and multiple
|
|
13
|
+
plaintext values share each one. Truncation makes the index non-injective, so
|
|
14
|
+
exact per-value frequency is not readable from it. It does **not** make the
|
|
15
|
+
distribution uniform: bucket occupancy remains a deterministic function of the
|
|
16
|
+
plaintext distribution, so a field whose values are heavily skewed still shows
|
|
17
|
+
a correspondingly skewed beacon histogram. Truncation raises the anonymity set
|
|
18
|
+
to roughly ``rows / buckets``; it does not conceal the shape of the data.
|
|
19
|
+
* **Access patterns.** Whatever the application does with ``WHERE beacon = ?``
|
|
20
|
+
is visible to anyone who can observe queries or read query logs.
|
|
21
|
+
|
|
22
|
+
Consequences for choosing a width — ``bits`` and dataset size are a joint
|
|
23
|
+
decision, not a constant:
|
|
24
|
+
|
|
25
|
+
* A width is only as strong as its bucket occupancy. On a 500-row table one byte
|
|
26
|
+
gives ~2 rows per bucket, which is close to exact equality; on a million rows
|
|
27
|
+
it gives ~4000. Call :func:`suggest_beacon_bits` with the real row count.
|
|
28
|
+
* Storage is byte-aligned, so **4 and 8 bits are the same index**, and 9..16 bits
|
|
29
|
+
are the same index. There are not four levels between 4 and 16 bits; there are
|
|
30
|
+
two widths. Do not describe 4-bit and 8-bit beacons as different strengths.
|
|
31
|
+
* Do not beacon a low-cardinality column (a status flag, a country, a boolean).
|
|
32
|
+
With a handful of distinct values the bucket histogram mirrors the plaintext
|
|
33
|
+
histogram no matter how the bits are spent.
|
|
34
|
+
* Do not beacon a column that is never the subject of an equality lookup.
|
|
35
|
+
|
|
36
|
+
**A beacon hit is not proof of equality.** Collisions are by design, so
|
|
37
|
+
:func:`beacon_matches` proves only that two values landed in the same bucket.
|
|
38
|
+
The confirmation step is to decrypt the shortlisted row and compare the
|
|
39
|
+
plaintext. Look up the bucket, narrow with ``beacon_matches``, confirm by
|
|
40
|
+
decryption.
|
|
41
|
+
|
|
42
|
+
Nothing here is wired into the default ``FloorVault`` surface: importing this
|
|
43
|
+
module cannot add a beacon to anything, and the index subkey is derived only
|
|
44
|
+
when a caller asks for it via :func:`derive_beacon_key`.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import hashlib
|
|
50
|
+
import hmac
|
|
51
|
+
import struct
|
|
52
|
+
import warnings
|
|
53
|
+
from typing import Union
|
|
54
|
+
|
|
55
|
+
from cryptography.hazmat.primitives import hashes
|
|
56
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
57
|
+
|
|
58
|
+
from .memory import HardenedMemoryKey
|
|
59
|
+
|
|
60
|
+
#: Narrowest supportable beacon. Bits below 8 all store one byte, so this is a
|
|
61
|
+
#: floor for error messages rather than a distinct width.
|
|
62
|
+
MIN_BEACON_BITS = 4
|
|
63
|
+
|
|
64
|
+
#: Widest supportable beacon (8 bytes of HMAC-SHA256 output).
|
|
65
|
+
MAX_BEACON_BITS = 64
|
|
66
|
+
|
|
67
|
+
#: HKDF domain-separation label for the beacon subkey. Distinct from the
|
|
68
|
+
#: ``floorvault-v1-aes-siv`` label so the index key and the AEAD key are
|
|
69
|
+
#: independent: reusing one key across two cryptographic purposes is exactly
|
|
70
|
+
#: the class of defect the subkey separation exists to prevent.
|
|
71
|
+
_BEACON_KEY_INFO = b"floorvault-v1-beacon-index"
|
|
72
|
+
|
|
73
|
+
_KEY_BYTES = 32
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _coerce_key_material(
|
|
77
|
+
key: Union[bytes, bytearray, HardenedMemoryKey], *, what: str
|
|
78
|
+
) -> Union[bytes, bytearray, memoryview]:
|
|
79
|
+
"""Coerce a key argument to a bytes-like buffer, accepting a hardened handle.
|
|
80
|
+
|
|
81
|
+
Shared by :func:`compute_beacon` (key, at least ``_KEY_BYTES``) and
|
|
82
|
+
:func:`derive_beacon_key` (master key, exactly ``_KEY_BYTES``) so the
|
|
83
|
+
isinstance dispatch and type error live in one place. Callers apply their
|
|
84
|
+
own length contract on top.
|
|
85
|
+
|
|
86
|
+
A hardened handle is consumed through :meth:`HardenedMemoryKey.get_buffer`,
|
|
87
|
+
NOT ``get_bytes()``. ``get_bytes()`` returns an immutable ``bytes`` object,
|
|
88
|
+
which is an un-wipeable ghost of key material on the Python heap - and a
|
|
89
|
+
beacon is computed once per indexed row, so that ghost is per-call, not
|
|
90
|
+
once. ``get_buffer()`` is a zero-copy view over the locked mapping, the same
|
|
91
|
+
choice ``core.py:250-256`` makes for the AEAD subkey and for the same
|
|
92
|
+
reason.
|
|
93
|
+
|
|
94
|
+
Callers that need a mutable buffer (HMAC) copy into a ``bytearray`` and wipe
|
|
95
|
+
it, which is cheap and still bounded; nothing here hands back an immutable
|
|
96
|
+
copy of secret key material.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(key, HardenedMemoryKey):
|
|
99
|
+
return key.get_buffer()
|
|
100
|
+
if isinstance(key, (bytes, bytearray, memoryview)):
|
|
101
|
+
return key
|
|
102
|
+
raise TypeError(f"{what} must be bytes, bytearray, or HardenedMemoryKey")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_key(
|
|
106
|
+
key: Union[bytes, bytearray, HardenedMemoryKey],
|
|
107
|
+
) -> Union[bytes, bytearray, memoryview]:
|
|
108
|
+
"""Return 32+ bytes of key material, accepting a hardened handle.
|
|
109
|
+
|
|
110
|
+
Returns the buffer as-is rather than re-wrapping it in ``bytes``: for a
|
|
111
|
+
hardened handle that re-wrap would reintroduce exactly the un-wipeable heap
|
|
112
|
+
copy this function exists to avoid.
|
|
113
|
+
"""
|
|
114
|
+
key_bytes = _coerce_key_material(key, what="beacon key")
|
|
115
|
+
if len(key_bytes) < _KEY_BYTES:
|
|
116
|
+
raise ValueError(f"beacon key must be at least {_KEY_BYTES} bytes")
|
|
117
|
+
return key_bytes
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _snapshot_key(
|
|
121
|
+
key_material: Union[bytes, bytearray, memoryview],
|
|
122
|
+
*,
|
|
123
|
+
owner: HardenedMemoryKey | None = None,
|
|
124
|
+
) -> bytearray:
|
|
125
|
+
"""Copy key material into a MUTABLE buffer for immediate use.
|
|
126
|
+
|
|
127
|
+
Returns a ``bytearray`` rather than ``bytes`` so the caller can zero it: an
|
|
128
|
+
immutable copy would reintroduce exactly the un-wipeable heap ghost this
|
|
129
|
+
module avoids. Every caller treats the returned buffer as its own to wipe.
|
|
130
|
+
|
|
131
|
+
:meth:`HardenedMemoryKey.get_buffer` hands out a *live* view of the locked
|
|
132
|
+
mapping, and ``wipe()`` zeroes that mapping in place. ``wipe()`` sets
|
|
133
|
+
``is_wiped`` BEFORE the memset, so a caller that obtains a view and copies
|
|
134
|
+
it a moment later has a window in which a concurrent ``wipe()`` has already
|
|
135
|
+
begun - or finished - while the view still reads pre-zeroed key bytes.
|
|
136
|
+
|
|
137
|
+
The dangerous part is that the failure is SILENT: an HMAC over 32 zero bytes
|
|
138
|
+
is a perfectly well-formed beacon of the correct length, so it indexes
|
|
139
|
+
cleanly and every later equality lookup simply misses the row. Nothing
|
|
140
|
+
raises, nothing looks corrupt.
|
|
141
|
+
|
|
142
|
+
So the copy checks afterwards instead. When ``owner`` is given and reports
|
|
143
|
+
``is_wiped`` once the copy has completed, the snapshot is wiped and refused
|
|
144
|
+
with ``RuntimeError``, matching what ``get_bytes()`` already does when
|
|
145
|
+
called on a wiped key. This closes the corruption without holding a lock
|
|
146
|
+
across the HMAC operation, which would serialise the hot path, and a
|
|
147
|
+
snapshot that completed before a later wipe remains valid to use.
|
|
148
|
+
|
|
149
|
+
The owner flag - never the bytes - identifies a wipe: an all-zero buffer is
|
|
150
|
+
a legitimate key value the public API must compute under, so the bytes
|
|
151
|
+
cannot tell "the key is zero" from "the handle was wiped".
|
|
152
|
+
"""
|
|
153
|
+
snapshot = bytearray(key_material)
|
|
154
|
+
if owner is not None and owner.is_wiped:
|
|
155
|
+
for index in range(len(snapshot)):
|
|
156
|
+
snapshot[index] = 0
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
"beacon key owner was wiped between obtaining the buffer and "
|
|
159
|
+
"copying it; refusing to compute a beacon under a wiped key (such "
|
|
160
|
+
"a beacon is well-formed but wrong, and would silently miss every "
|
|
161
|
+
"lookup)"
|
|
162
|
+
)
|
|
163
|
+
return snapshot
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def beacon_bucket_bytes(bits: int) -> int:
|
|
167
|
+
"""Bytes needed to store ``bits`` of beacon entropy (1..8 bytes for 4..64).
|
|
168
|
+
|
|
169
|
+
Storage is byte-aligned, so the mapping is many-to-one: 4..8 bits all store
|
|
170
|
+
one byte and 9..16 bits all store two. Callers that report a width to a user
|
|
171
|
+
must report the stored size, not the requested ``bits``.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
TypeError: ``bits`` is not an ``int`` (``bool`` included, deliberately).
|
|
175
|
+
ValueError: ``bits`` is outside ``[MIN_BEACON_BITS, MAX_BEACON_BITS]``.
|
|
176
|
+
"""
|
|
177
|
+
if isinstance(bits, bool) or not isinstance(bits, int):
|
|
178
|
+
raise TypeError("beacon bits must be an integer")
|
|
179
|
+
if not MIN_BEACON_BITS <= bits <= MAX_BEACON_BITS:
|
|
180
|
+
raise ValueError(f"beacon bits must be in [{MIN_BEACON_BITS}, {MAX_BEACON_BITS}]")
|
|
181
|
+
return (bits + 7) // 8
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _canonical_payload(value: str, *, scope: str) -> bytes:
|
|
185
|
+
"""Length-prefixed ``scope`` then ``value``, so boundaries cannot be blurred.
|
|
186
|
+
|
|
187
|
+
A plain ``f"{scope}\\x00{value}"`` separator is ambiguous: ``("a", "b\\x00c")``
|
|
188
|
+
and ``("a\\x00b", "c")`` serialise identically, so a caller controlling the
|
|
189
|
+
scope can collide with a value under a twisted scope. Prefixing the scope
|
|
190
|
+
with its own length makes the encoding injective over the pair.
|
|
191
|
+
"""
|
|
192
|
+
if not isinstance(value, str):
|
|
193
|
+
raise TypeError(f"beacon value must be str, got {type(value).__name__}")
|
|
194
|
+
if not isinstance(scope, str) or not scope.strip():
|
|
195
|
+
raise ValueError("beacon scope must be a non-empty string for domain separation")
|
|
196
|
+
scope_bytes = scope.encode("utf-8")
|
|
197
|
+
return struct.pack(">I", len(scope_bytes)) + scope_bytes + value.encode("utf-8")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def compute_beacon(
|
|
201
|
+
value: str,
|
|
202
|
+
*,
|
|
203
|
+
scope: str,
|
|
204
|
+
key: Union[bytes, bytearray, HardenedMemoryKey],
|
|
205
|
+
bits: int = 8,
|
|
206
|
+
) -> bytes:
|
|
207
|
+
"""Return the truncated, keyed equality beacon for ``value`` under ``scope``.
|
|
208
|
+
|
|
209
|
+
The result is a deterministic prefix of HMAC-SHA256 over the length-prefixed
|
|
210
|
+
``(scope, value)`` pair, truncated to ``beacon_bucket_bytes(bits)`` bytes.
|
|
211
|
+
Store it in an indexed column beside the ciphertext, look the bucket up with
|
|
212
|
+
an ordinary SQL equality predicate, narrow candidates with
|
|
213
|
+
:func:`beacon_matches`, and confirm the match by decrypting.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
value: Plaintext value to index.
|
|
217
|
+
scope: Domain separator, e.g. ``"users.email"``. Must be non-empty.
|
|
218
|
+
key: Beacon subkey, at least 32 bytes. Use :func:`derive_beacon_key`.
|
|
219
|
+
bits: Requested width in ``[4, 64]``; storage rounds up to whole bytes.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
``ceil(bits / 8)`` bytes.
|
|
223
|
+
"""
|
|
224
|
+
bucket_size = beacon_bucket_bytes(bits)
|
|
225
|
+
# _snapshot_key returns a MUTABLE buffer (refusing a wiped live view) that
|
|
226
|
+
# this call owns; zero it as soon as the HMAC has consumed it.
|
|
227
|
+
key_buffer = _snapshot_key(
|
|
228
|
+
_validate_key(key), owner=key if isinstance(key, HardenedMemoryKey) else None
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
digest = hmac.new(key_buffer, _canonical_payload(value, scope=scope), hashlib.sha256)
|
|
232
|
+
return digest.digest()[:bucket_size]
|
|
233
|
+
finally:
|
|
234
|
+
for index in range(len(key_buffer)):
|
|
235
|
+
key_buffer[index] = 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def beacon_matches(
|
|
239
|
+
value: str,
|
|
240
|
+
*,
|
|
241
|
+
scope: str,
|
|
242
|
+
key: Union[bytes, bytearray, HardenedMemoryKey],
|
|
243
|
+
beacon: Union[bytes, bytearray, memoryview],
|
|
244
|
+
bits: int = 8,
|
|
245
|
+
) -> bool:
|
|
246
|
+
"""True iff ``value``'s beacon equals ``beacon`` — bucket agreement, only.
|
|
247
|
+
|
|
248
|
+
Used to discard candidate rows whose bucket disagrees before attempting a
|
|
249
|
+
decryption. A ``True`` result means the two values share a bucket, which
|
|
250
|
+
collisions make non-unique: **confirm equality by decrypting the candidate
|
|
251
|
+
and comparing the plaintext.** Never treat the result as an equality proof.
|
|
252
|
+
"""
|
|
253
|
+
candidate = compute_beacon(value, scope=scope, key=key, bits=bits)
|
|
254
|
+
return hmac.compare_digest(candidate, bytes(beacon))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def derive_beacon_key(master_key: Union[bytes, bytearray, HardenedMemoryKey]) -> bytes:
|
|
258
|
+
"""Derive the 32-byte beacon subkey from a 32-byte master key.
|
|
259
|
+
|
|
260
|
+
HKDF-SHA256 with the domain-separating label ``floorvault-v1-beacon-index``,
|
|
261
|
+
which is distinct from the ``floorvault-v1-aes-siv`` label used for the AEAD
|
|
262
|
+
subkey. There is no structural collision resistance between the two HFDF
|
|
263
|
+
outputs in general, so the two must not be derived with the same label; this
|
|
264
|
+
function exists so callers do not invent one.
|
|
265
|
+
|
|
266
|
+
Returns the key as ``bytes``. If the caller's master key is a hardened
|
|
267
|
+
handle, the derived key is returned to the caller and is that caller's to
|
|
268
|
+
wipe; :class:`BeaconIndexer` never retains more than the object it is given.
|
|
269
|
+
"""
|
|
270
|
+
source = _snapshot_key(
|
|
271
|
+
_coerce_key_material(master_key, what="master key"),
|
|
272
|
+
owner=master_key if isinstance(master_key, HardenedMemoryKey) else None,
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
if len(source) != _KEY_BYTES:
|
|
276
|
+
raise ValueError(f"master key must be exactly {_KEY_BYTES} bytes")
|
|
277
|
+
return HKDF(
|
|
278
|
+
algorithm=hashes.SHA256(),
|
|
279
|
+
length=_KEY_BYTES,
|
|
280
|
+
salt=None,
|
|
281
|
+
info=_BEACON_KEY_INFO,
|
|
282
|
+
).derive(source)
|
|
283
|
+
finally:
|
|
284
|
+
# The snapshot is ours; zero it rather than leaving key-derived material
|
|
285
|
+
# on the heap. The returned subkey is the caller's to wipe.
|
|
286
|
+
for index in range(len(source)):
|
|
287
|
+
source[index] = 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def suggest_beacon_bits(expected_rows: int, target_bucket_size: int = 8) -> int:
|
|
291
|
+
"""Recommend a stored width sized to a dataset.
|
|
292
|
+
|
|
293
|
+
There is no universally safe width: the index must be coarse enough that an
|
|
294
|
+
observer cannot read exact equality or frequency, and fine enough that a
|
|
295
|
+
lookup does not have to decrypt most of the table. Given ``expected_rows``
|
|
296
|
+
indexed values and a target average bucket occupancy, this returns the
|
|
297
|
+
smallest byte-aligned width whose average occupancy (``rows / 256**bytes``)
|
|
298
|
+
is at or below the target.
|
|
299
|
+
|
|
300
|
+
The result is a multiple of 8 in ``[8, 64]``. At very large ``expected_rows``
|
|
301
|
+
it clamps to 64 and the requested occupancy may be unreachable — the caller
|
|
302
|
+
should then stop trusting the beacon as a narrow filter.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
expected_rows: Number of indexed rows.
|
|
306
|
+
target_bucket_size: Desired average rows per bucket (coarser = larger).
|
|
307
|
+
"""
|
|
308
|
+
for name, val in (("expected_rows", expected_rows), ("target_bucket_size", target_bucket_size)):
|
|
309
|
+
if isinstance(val, bool) or not isinstance(val, int):
|
|
310
|
+
raise TypeError(f"{name} must be an integer")
|
|
311
|
+
if val < 1:
|
|
312
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
313
|
+
|
|
314
|
+
max_bytes = MAX_BEACON_BITS // 8
|
|
315
|
+
bucket_bytes = 1
|
|
316
|
+
while bucket_bytes < max_bytes:
|
|
317
|
+
if expected_rows / (256**bucket_bytes) <= target_bucket_size:
|
|
318
|
+
break
|
|
319
|
+
bucket_bytes += 1
|
|
320
|
+
return bucket_bytes * 8
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class BeaconIndexer:
|
|
324
|
+
"""A beacon subkey bound once, with a default width.
|
|
325
|
+
|
|
326
|
+
Convenience wrapper for callers that compute many beacons under one key.
|
|
327
|
+
Holds the key object it was given (a ``HardenedMemoryKey`` stays the
|
|
328
|
+
caller's to wipe; a ``bytes`` key cannot be wiped from here).
|
|
329
|
+
|
|
330
|
+
Unlike :func:`compute_beacon`, this constructor **refuses a width that is
|
|
331
|
+
not byte-aligned**. A caller asking for 7 bits believes it gets 128 buckets
|
|
332
|
+
and actually stores 256, which is a security claim that does not match the
|
|
333
|
+
index; refusing the request is cheaper than documenting around it. Use
|
|
334
|
+
:attr:`bucket_bytes` to report the width the index really has.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
def __init__(
|
|
338
|
+
self,
|
|
339
|
+
key: Union[bytes, bytearray, HardenedMemoryKey],
|
|
340
|
+
*,
|
|
341
|
+
bits: int = 8,
|
|
342
|
+
expected_rows: int | None = None,
|
|
343
|
+
) -> None:
|
|
344
|
+
beacon_bucket_bytes(bits) # range check first, for the clearer message
|
|
345
|
+
if bits % 8 != 0:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"beacon width must be byte-aligned (8, 16, ... 64), got {bits}: "
|
|
348
|
+
f"{bits} bits stores {beacon_bucket_bytes(bits)} byte(s), i.e. "
|
|
349
|
+
f"{256 ** beacon_bucket_bytes(bits)} buckets, not {2**bits}"
|
|
350
|
+
)
|
|
351
|
+
_validate_key(key) # fail on absent/short key material at construction
|
|
352
|
+
self._key = key
|
|
353
|
+
self.bits = bits
|
|
354
|
+
if expected_rows is not None:
|
|
355
|
+
if isinstance(expected_rows, bool) or not isinstance(expected_rows, int):
|
|
356
|
+
raise TypeError("expected_rows must be an integer")
|
|
357
|
+
if expected_rows < 1:
|
|
358
|
+
raise ValueError("expected_rows must be a positive integer")
|
|
359
|
+
# A width wider than suggested puts fewer rows in each bucket than
|
|
360
|
+
# the target occupancy, i.e. moves the index nearer exact-equality
|
|
361
|
+
# visibility for an observer of the beacon column. Narrower is the
|
|
362
|
+
# safe direction (larger anonymity set, slower narrowing) and earns
|
|
363
|
+
# no warning.
|
|
364
|
+
suggested = suggest_beacon_bits(expected_rows)
|
|
365
|
+
if bits > suggested:
|
|
366
|
+
occupancy = expected_rows / self.bucket_count
|
|
367
|
+
warnings.warn(
|
|
368
|
+
f"bits={bits} on ~{expected_rows} rows leaves ~{occupancy:.1f} "
|
|
369
|
+
f"rows per beacon bucket, below the suggested occupancy - "
|
|
370
|
+
f"the index is closer to exact equality than "
|
|
371
|
+
f"suggest_beacon_bits({expected_rows}) -> {suggested} bits",
|
|
372
|
+
UserWarning,
|
|
373
|
+
stacklevel=2,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def bucket_bytes(self) -> int:
|
|
378
|
+
"""Bytes actually stored per beacon — the width that decides leakage."""
|
|
379
|
+
return beacon_bucket_bytes(self.bits)
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def bucket_count(self) -> int:
|
|
383
|
+
"""Number of distinct buckets, i.e. ``256 ** bucket_bytes``."""
|
|
384
|
+
return 256**self.bucket_bytes
|
|
385
|
+
|
|
386
|
+
def beacon(self, value: str, *, scope: str) -> bytes:
|
|
387
|
+
"""Stored beacon for ``value`` at this indexer's width."""
|
|
388
|
+
return compute_beacon(value, scope=scope, key=self._key, bits=self.bits)
|
|
389
|
+
|
|
390
|
+
def matches(
|
|
391
|
+
self, value: str, *, scope: str, beacon: Union[bytes, bytearray, memoryview]
|
|
392
|
+
) -> bool:
|
|
393
|
+
"""Bucket agreement only — confirm equality by decrypting the candidate."""
|
|
394
|
+
return beacon_matches(value, scope=scope, key=self._key, beacon=beacon, bits=self.bits)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
__all__ = [
|
|
398
|
+
"MAX_BEACON_BITS",
|
|
399
|
+
"MIN_BEACON_BITS",
|
|
400
|
+
"BeaconIndexer",
|
|
401
|
+
"beacon_bucket_bytes",
|
|
402
|
+
"beacon_matches",
|
|
403
|
+
"compute_beacon",
|
|
404
|
+
"derive_beacon_key",
|
|
405
|
+
"suggest_beacon_bits",
|
|
406
|
+
]
|