FloorVault
Your data. Its place. Cryptographically bound.
Context-bound, misuse-resistant field encryption for SQLite — AES-256-SIV (RFC 5297), no SQLCipher, no C extension.
Quickstart · How it works · Install · SQLite adapter · Boundaries · Security model · Docs
Beta — no external security audit. Review the security model before using FloorVault for production secrets.
| Bound to its place | Your existing SQLite | Fail-closed key custody |
|---|---|---|
| Every ciphertext authenticates against its table, record, column, schema, and application instance — moved or replayed under different coordinates, verification fails instead of returning wrong plaintext. | Field-level encryption for ordinary sqlite3 databases — plus an opt-in SQLAlchemy adapter — with no SQLCipher or custom SQLite build. |
macOS Keychain, Windows DPAPI, Linux Secret Service, or Vault Transit — never a silent downgrade to a weaker store. |
Quickstart
resolve_key() uses whatever custody tier is available on the host and fails closed when none applies — see
Key custody. The wrong-context decrypt at the end is the point of the library: a different
table, record, column, schema, or app instance fails authentication rather than returning wrong plaintext.
from floorvault import AdaptiveKeyProvider, DecryptionVerificationError, FloorVault
master_key = AdaptiveKeyProvider(service_name="my-app").resolve_key()
crypto = FloorVault(master_key, app_instance_id="my-app-instance")
ciphertext = crypto.encrypt("secret value", table="credentials", record_id="user-123", column="api_key")
assert crypto.decrypt(ciphertext, table="credentials", record_id="user-123", column="api_key") == "secret value"
try:
crypto.decrypt(ciphertext, table="credentials", record_id="user-456", column="api_key")
except DecryptionVerificationError:
pass
else:
raise AssertionError("ciphertext accepted under the wrong context")
Never hard-code a production master key in source — use an OS-backed provider or an external secret source.
How it works
FloorVault authenticates each value against the place it belongs. Every envelope's associated data binds five
coordinates — table, record_id, column, schema_id/schema_version, and the app_instance_id of the
writer — so ciphertext moved to another row, column, schema, or application instance fails verification rather
than decrypting. The master key derives an AES-SIV key with HKDF-SHA256; each field is sealed with
AES-256-SIV (RFC 5297) into an FLV2 envelope in an ordinary BLOB column. An optional caller-held revision
can bind a per-record version into the same context — replay detection, not whole-database rollback protection.
Install
pip install floorvault
uv add floorvault
Published artifacts are reproducible and carry Sigstore provenance — see Verifying a download.
Add extras as needed — floorvault[macos], floorvault[macos,sqlalchemy], …:
| Extra | Enables |
|---|---|
macos |
macOS Keychain custody tier |
linux |
Linux Secret Service custody tier |
sqlalchemy |
SqlAlchemyEncryption ORM adapter |
libsql |
EncryptedLibSqlTable — same contract over libSQL (local files, embedded replicas, Turso) |
dev |
Test, lint, and security-gate toolchain |
A stock install does not provide an OS key store on every host: the macos/linux extras enable Keychain and
Secret Service, Windows uses built-in DPAPI, and the local-file tier stays off unless you opt in. With no
usable provider, resolve_key() fails closed with a KeyProviderError naming the remedies — no unprotected key file.
Existing SQLite tables
FloorVault does not own your schema or transactions. Add an encrypted BLOB column to an existing table, then use the adapter:
import sqlite3
from floorvault import AdaptiveKeyProvider, EncryptedSQLiteTable, FloorVault
connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE users (id TEXT PRIMARY KEY, api_token_cipher BLOB)")
connection.execute("INSERT INTO users (id) VALUES (?)", ("u1",))
master_key = AdaptiveKeyProvider(service_name="my-app").resolve_key()
crypto = FloorVault(master_key, app_instance_id="my-app")
fields = EncryptedSQLiteTable(connection, crypto, "users", id_column="id")
fields.store("u1", "api_token_cipher", "secret-token")
connection.commit()
assert fields.load("u1", "api_token_cipher") == "secret-token"
Table and column identifiers are validated before SQL is constructed; record IDs and values stay bound parameters or cryptographic inputs. A missing record is an error — the adapter never inserts one accidentally.
Key custody
AdaptiveKeyProvider resolves an available custody tier rather than silently weakening an explicitly requested
one — an environment variable first (FLOOR_VAULT_KEY, VAULT_MASTER_KEY, or the legacy APPSTATE_KEY, 64 hex
characters), then the OS-native tier (macOS Keychain, needs the macos extra; Windows DPAPI, built in,
a store outside the user profile is refused; Linux Secret Service, needs the linux extra), and only then
a 0600 local file (Tier 3, off unless allow_disk_fallback=True; strict=True forbids it outright).
Resolving a key with no OS store — macOS without its extra, or a headless container — fails closed with a
KeyProviderError that names the remedies (resolution options in the custody guide below).
Custody caveats. The 0600 local file is protected by the filesystem and OS-account boundary only — not
equivalent to hardware-backed or OS-managed custody, and anyone who can copy it recovers the key. A present but
unusable native backend raises CustodyDowngradeError instead of downgrading. Constructing a key handle sets
RLIMIT_CORE to 0 process-wide and never restores it — a core dump would write the held master key to disk.
Live CI coverage exists for Windows DPAPI only; the macOS and Linux tiers are unit-tested but not
live-verified — per-tier status in SECURITY.md.
Security boundaries
| Protects against | Does not provide |
|---|---|
| Database or ciphertext theft; accidental cryptographic misuse; moving ciphertext to another authenticated context; some forms of key-memory exposure where platform hardening succeeds. | Process isolation against same-user malware; endpoint compromise protection; hardware-backed trust by itself; whole-database freshness or rollback protection; protection from plaintext copies created by Python, OpenSSL, or other dependencies. |
For replay protection, bind encryption to a caller-controlled revision an attacker cannot roll back with the database — full statement: SECURITY.md.
Guides
SQLAlchemy ORM adapter — plaintext attributes encrypt at assignment; bulk writes refused
With the sqlalchemy extra installed, SqlAlchemyEncryption binds encrypted fields onto mapped classes:
plaintext-facing attributes are descriptors that encrypt at assignment, and writes that cannot bind per-record
coordinates are refused.
from sqlalchemy import Column, LargeBinary, String, create_engine
from sqlalchemy.orm import DeclarativeBase
from floorvault import AdaptiveKeyProvider, FloorVault, SqlAlchemyEncryption
crypto = FloorVault(AdaptiveKeyProvider(service_name="my-app").resolve_key(), app_instance_id="my-app")
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
tenant = Column(String, nullable=False)
ssn_ct = Column(LargeBinary, nullable=True)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
vault = SqlAlchemyEncryption(crypto, schema_id="my-app.v1")
vault.protect(User, id_attr="id", tenant_attr="tenant", fields={"ssn": "ssn_ct"})
Session = vault.session_factory(bind=engine)
with Session() as session:
user = User(id="u1", tenant="acme") # identity attrs first — binding needs them
user.ssn = "123-45-6789" # encrypts here; only ciphertext is mapped
session.add(user)
session.commit()
assert session.get(User, "u1").ssn == "123-45-6789"
Fail-closed invariants (full contract: SPEC.md §14.1):
obj.ssn_ct = b"plaintext"is refused at assignment — only envelope-shaped values may occupy a ciphertext column.session.execute(insert(User).values(ssn_ct=...)), executemany sets,Query.update, and the legacy bulk APIs (bulk_insert_mappings/bulk_update_mappings/bulk_save_objects) on ciphertext columns or protected classes raiseUnsupportedWriteError.tenant_attrbinds the tenant into the record coordinate, so a ciphertext cannot be replayed across tenants;revision_attrbinds a per-record revision (same-coordinate replay detection, not whole-database rollback protection).
Migrate existing plaintext columns — atomic, verified, residue-aware
Encrypt a plaintext column into an existing destination column without deleting the source. This continues
the existing-tables example (crypto) on its own in-memory table:
import sqlite3
from floorvault import migrate_plaintext_column, verify_encrypted_column
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE TABLE users (id TEXT PRIMARY KEY, private_value TEXT, private_value_cipher BLOB)"
)
connection.execute("INSERT INTO users (id, private_value) VALUES (?, ?)", ("u1", "plain-secret"))
migrate_plaintext_column(
connection, crypto, table="users", id_column="id",
source_column="private_value", destination_column="private_value_cipher",
)
connection.commit()
assert verify_encrypted_column(
connection, crypto, table="users", id_column="id",
source_column="private_value", destination_column="private_value_cipher",
) == 1
The migration validates identifiers, refuses a populated destination, binds each value to its real record ID,
and rolls back on failure. Remove the plaintext column only after independent verification and a backup policy —
drop_plaintext_column() arms PRAGMA secure_delete, checkpoints the WAL, and optionally VACUUMs, but
filesystem residue can survive; destroying the file is the only complete guarantee.
Searchable beacons (opt-in) — exact-match lookup with an explicit leakage trade
Exact-match lookups over an encrypted column need an indexed value beside the ciphertext. Storing one leaks
something, so this is opt-in and a trade made deliberately. The snippet is self-contained after crypto and
master_key from the existing-tables example;
tests/test_searchable_beacons.py executes the same workflow.
import sqlite3
from floorvault.beacons import BeaconIndexer, derive_beacon_key, suggest_beacon_bits
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE TABLE contacts (id TEXT PRIMARY KEY, email_cipher BLOB, email_beacon BLOB)"
)
connection.execute("CREATE INDEX contacts_email_beacon ON contacts (email_beacon)")
record_id, email = "c1", "alice@example.com"
# Size the width to the table, don't pick a constant: the anonymity a beacon
# gives is roughly one bucket's occupancy.
indexer = BeaconIndexer(derive_beacon_key(master_key), bits=suggest_beacon_bits(200_000))
# On write: store the ciphertext and the beacon in an indexed column.
connection.execute(
"INSERT INTO contacts (id, email_cipher, email_beacon) VALUES (?, ?, ?)",
(record_id, crypto.encrypt(email, table="contacts", record_id=record_id, column="email_cipher"),
indexer.beacon(email, scope="contacts.email")),
)
connection.commit()
# On read: the bucket narrows candidates; decryption confirms the match.
bucket = indexer.beacon(email, scope="contacts.email")
for candidate_id, ciphertext in connection.execute(
"SELECT id, email_cipher FROM contacts WHERE email_beacon = ?", (bucket,)
):
if crypto.decrypt(ciphertext, table="contacts", record_id=candidate_id, column="email_cipher") == email:
break # confirmed
else:
raise AssertionError("beacon lookup found no matching record")
What this costs: equal values produce equal beacons, so unequal beacons prove unequal values; bucket occupancy
still tracks the plaintext distribution, so a skewed column shows a skewed beacon histogram. Truncation makes
the index non-injective — it does not make the data uniform — so size the width to the row count, and do not
beacon a low-cardinality column or one you never look up by equality. Widths are byte-aligned (4 and 8 bits are
the same index); BeaconIndexer rejects a width it cannot store rather than rounding it. A beacon hit is not
proof of equality — always confirm by decrypting. Full statement: SECURITY.md §5.
Key-custody options and Vault Transit — resolving a key when no OS store applies
Three ways to satisfy resolve_key():
import os
from floorvault import AdaptiveKeyProvider, FloorVault
# 1. Explicit key, 64 hex characters, from your own secret source.
crypto = FloorVault(bytes.fromhex(os.environ["APPSTATE_KEY"]), app_instance_id="my-app")
# 2. A 0600 local key file, created on first use and reused afterwards. This is
# Tier 3: anyone who can copy the file can recover the key.
crypto = FloorVault(
AdaptiveKeyProvider(service_name="my-app", allow_disk_fallback=True).resolve_key(),
app_instance_id="my-app",
)
# 3. Install the OS-native tier for the platform (floorvault[macos] on macOS).
crypto = FloorVault(AdaptiveKeyProvider(service_name="my-app").resolve_key(), app_instance_id="my-app")
VaultTransitProvider is a fourth option: it keeps the master key wrapped by a HashiCorp Vault Transit key
instead of a local file; the wrapped blob lives in a governed on-disk generation store
(SPEC.md §10.6–10.7):
from floorvault.providers.vault_transit import VaultTransitProvider
provider = VaultTransitProvider(
vault_addr="https://vault.internal:8200",
token=os.environ["VAULT_TOKEN"], # Transit datakey/decrypt/rewrap perms
key_name="floorvault-master",
store_dir="/var/lib/myapp/floorvault", # 0700; holds store.id + generations
app_instance_id="my-app",
)
crypto = FloorVault(provider.resolve_key(), app_instance_id="my-app")
# Rewrap under a new Transit KEK version (CAS-publishes a new generation):
provider.rewrap()
Every Vault failure raises CustodyDowngradeError; a configured provider never silently falls back to file
custody. HTTPS only, verified TLS, bounded timeouts/retries, redirects refused unless a standby host is
trusted. The resolved key is cached briefly (300 s default) — that bounds Transit latency, not revocation
protection. Full setup: docs/VAULT-TRANSIT.md.
Rotation and recovery — resumable re-keying, authenticated recovery bundles
Rotate a store under a new key with resumable progress and post-rotation verification:
from floorvault import AdaptiveKeyProvider, FloorVault, KeyRing, rotate_vault_store
# VaultStore is the structured item store this rotation operates on; it is not
# re-exported at the top level.
from floorvault.vaultkit import VaultStore
from pathlib import Path
# VaultStore does NOT expand "~" -- a literal tilde would become a directory
# named "~" relative to the working directory. Expand it explicitly.
VAULT_DIR = Path("~/.floor/vault").expanduser()
old_crypto = FloorVault(AdaptiveKeyProvider(service_name="my-app").resolve_key(), app_instance_id="my-app")
store = VaultStore(VAULT_DIR, crypto=old_crypto)
# A new_master_key from your key provider, wrapped in its own engine.
new_crypto = FloorVault(new_master_key, app_instance_id="my-app")
rotate_vault_store(store, source_ring=KeyRing({0: old_crypto}), new_vault=new_crypto, new_key_id=1)
# REQUIRED: the `store` above was built on old_crypto and CANNOT read the
# re-sealed records -- rotation sealed every envelope under the NEW master key.
# Rebuild the store on the new key and repoint every holder of the old one.
store = VaultStore(VAULT_DIR, crypto=new_crypto)
When rotating to different key material, rebuild the original store. Its convenience reads cannot
authenticate records sealed under the new master: resolve_secret, get_meta, and list_items raise
DecryptionVerificationError, while has_items() does not decrypt and cannot surface the mismatch. Rotation is
durable and verified — if the process exits before rebuilding and the provider still resolves the old key, the
store stays unreadable across restarts. Rebuild the store rather than mutating it, and make the new key
resolvable before you rotate. To read a store spanning generations, use KeyRing with read_sealed_item.
Create an authenticated recovery bundle using a separately protected recovery key — keep it separate from the
vault and its backups. This recipe requires master_key and recovery_key as distinct 32-byte bytes or
bytearray values from your secret source, not the HardenedMemoryKey handle returned by resolve_key():
from floorvault import recover_master_key, wrap_master_key
bundle = wrap_master_key(master_key, recovery_key)
recovered = recover_master_key(bundle, recovery_key)
The recovery bundle is not a substitute for protecting the recovery key.
Performance
A measured macOS benchmark using five independent 10,000-iteration runs and a 1,019-byte payload reported
(methodology and limits: docs/COMPARATIVE-BENCHMARK-2026-09-15.md):
| Operation | FloorVault | Fernet |
|---|---|---|
| Encrypt | 0.00583 ms | 0.00700 ms |
| Decrypt | 0.00517 ms | 0.00629 ms |
Workload-specific measurements, not universal claims; includes contextual AAD and envelope handling, no database I/O. Reproduce with uv run python scripts/benchmark_compare.py --iterations 10000 --json /tmp/floorvault-fernet.json.
CLI
floorvault inspect decrypts a field and prints the plaintext — treat its output as secret:
floorvault inspect local_vault.db users user-123 private_value_cipher.
Reporting a vulnerability
Do not report security issues in public GitHub issues or pull requests. Use GitHub private vulnerability
reporting on this repository (preferred), or email floorbond@pm.me — SECURITY.md has scope and PGP details.
Documentation
SECURITY.md— security model, limitations, and reportingdocs/SPEC.md— full protocol and adapter contractsdocs/VAULT-TRANSIT.md— Vault Transit custody setupdocs/RECORD-FORMAT-2026-09-15.md—FLV2envelope formatdocs/COMPARATIVE-BENCHMARK-2026-09-15.md— benchmark methodologydocs/CROSS-PLATFORM-CI-FINDINGS-2026-09-15.md— platform findings
Development
uv sync --extra dev
uv run pytest
uv run ruff check src/ tests/ scripts/ fuzz/
uv run ruff format --check src/ tests/ scripts/ fuzz/
uv run bandit -q -r src/ scripts/ fuzz/
bash scripts/security-check.sh # full gate; also needs gitleaks and semgrep on PATH
CI tests on Linux, macOS, and Windows across supported Python versions.
License
FloorVault is dual-licensed under MIT or Apache License 2.0, at your option.
Metadata
Release files for floorvault 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| floorvault-0.1.0.tar.gz | 572.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| floorvault-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 688.6 kB
Release files / floorvault-0.1.0.tar.gz
| Download URL | floorvault-0.1.0.tar.gz |
|---|---|
| Size | 572.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cc40891f2a0f9d3e0643fd743cb2df8f83960911d05daa167eab9589d724adc1
|
|
BLAKE2b-256 checksum How to use checksums |
b3a58d1854b162257fdef6abb9ac65a5dd3caa5085d18f18afe4ee5d304a1819
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency logRelease files / floorvault-0.1.0-py3-none-any.whl
| Download URL | floorvault-0.1.0-py3-none-any.whl |
|---|---|
| Size | 116.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0a4cebcd4fd821d2982619670d9fecbb61e1259e9230363da4f3d81f27252eae
|
|
BLAKE2b-256 checksum How to use checksums |
e2eae4772de7475eed5acd4bb4b03e84a66f8327801e1e3c6a14b51efc41a7be
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 8, 2026.
Transparency log