hadamask
Client-side protection for vector databases. Keyed orthogonal obfuscation for embeddings, AES-256-GCM for metadata. Similarity search stays exact.
Quick start · Security model · Store API · Databases · Key management · Frameworks · Design
Vector databases need the geometry of your embeddings to search them, so the vectors usually sit in the database exactly as your embedding model produced them, next to the documents' metadata. Anyone who gets a snapshot, a backup or an over-privileged database account gets both.
hadamask protects that data before it leaves your application:
- Embeddings go through a secret orthogonal transform. Queries get the same transform, so the database computes exactly the same cosine, dot-product and L2 scores as on the clear vectors. Your index, your database and your recall stay the same.
- Metadata and document text are encrypted field by field with AES-256-GCM, with a fresh
nonce per value and length padding. An amount of
100.00and one of9,999,999.99produce ciphertexts of the same length. - Filters still work. Fields you declare
blindget keyed equality tokens. Fields you declareplaintextstay in clear and support ranges. - Keys stay with you. It is a library, not a proxy or a service. The master key can stay wrapped by HashiCorp Vault, AWS KMS, Google Cloud KMS or Azure Key Vault.
It works with Qdrant, Weaviate, Milvus, Chroma, pgvector, Elasticsearch, OpenSearch, Redis and MongoDB Atlas, behind one API, and with LangChain and LlamaIndex.
The method and its leakage are analysed in What a Secret Rotation Buys: Exact Retrieval and Measured Leakage in Vector Databases (Cernica & Popescu, Applied Sciences 2026).
Contents
- How it works
- Security model
- Install
- Quick start
- Core concepts · Choosing a transform: hadamard or dense
- The store API
- Supported databases
- SDK wrappers for Qdrant and Weaviate
- LangChain and LlamaIndex
- Key management
- Key rotation
- Command line
- Design
- Benchmarks
- Testing
- Roadmap
- Citation
How it works
flowchart LR
subgraph trusted["Your application (trusted)"]
A[Documents and queries] --> B[Embedding model]
B --> C["hadamask<br/>vectors: v ↦ Q·v<br/>metadata: AES-256-GCM"]
K[("Master key<br/>(Vault / KMS / HSM)")] -.-> C
end
C -- "transformed vectors<br/>ciphertexts, tokens" --> D[("Vector database<br/>any of 9 engines")]
D -- "ids, scores,<br/>ciphertexts" --> C
C --> E[Decrypted results]
- Your application embeds a document as usual.
- hadamask pads the vector to a power-of-two dimension and applies the keyed orthogonal map
Q = H·D2·P·H·D1, built from Walsh–Hadamard transforms, secret sign flips and a secret permutation. BecauseQis orthogonal,⟨Qu, Qv⟩ = ⟨u, v⟩. - Each metadata field is serialized, padded, and encrypted with AES-256-GCM, or tokenized or left in clear, depending on your policy.
- The database indexes and searches what it receives, with no plugin and no configuration change. Queries get the same transform, so rankings and scores are those of the clear data.
- Results are decrypted in your process.
Security model
The threat is an attacker who obtains a copy of the database (a snapshot, a backup, a leaked dump or an over-privileged account) but not your application or your key.
| What is stored | Protection | What an attacker with a snapshot still learns |
|---|---|---|
| Encrypted fields (default for every field) | AES-256-GCM, fresh 96-bit nonce per value, field name and key id bound as associated data | That the field exists. Short values reveal nothing about their length; long values reveal a power-of-two size class, or nothing with fixed_lengths |
| Blind fields | Encrypted as above, plus an HMAC-SHA256 equality token | Which records share a value, and how often each value occurs |
| Plaintext fields | None. This is your explicit choice | Everything |
| Embeddings | Keyed orthogonal transform | All pairwise similarities (the Gram matrix): clusters, duplicates, nearest-neighbour graph, spectrum. Not the coordinates themselves |
What the paper measures about the embedding leakage:
- Search is exact: exhaustive top-10 sets are identical to clear search across three BEIR datasets and three encoders. Five of six database engines return identical sets; the sixth (Chroma) differs only within its own re-indexing variability.
- If an attacker knows enough plaintext–vector pairs (as many as the dimension), the map is recovered exactly.
- Without any pairs, public embeddings from the same distribution still support topic inference: median four-way topic accuracy of 0.81–0.87 in the paper's experiments.
Not covered: compromise of the application host or the key; attackers who can insert chosen documents and later read a snapshot; query and access-pattern leakage; server-side vectorizers (the database must never receive clear text or clear vectors).
Use hadamask where the threat is data at rest in a database you do not fully control. It raises the bar from "read the dump" to "mount an inference attack, with auxiliary data, against a geometry-only view". If you need the corpus geometry itself hidden, you need a cryptographic protocol or trusted hardware. See SECURITY.md.
Install
Install hadamask with the extra for your database:
pip install "hadamask[pgvector]" # or qdrant, weaviate, milvus, chroma, ...
pip install "hadamask[qdrant,langchain]" # several at once
pip install "hadamask[all]" # every database, framework and key manager
pip install hadamask alone installs only the core (vector transform and metadata
encryption, which depend on numpy and cryptography). Each extra adds the client SDK
it needs, so a pgvector deployment does not pull in a dozen other database SDKs. If an
extra is missing, the import tells you which one to install.
| Extra | Adds |
|---|---|
qdrant weaviate milvus chroma pgvector |
vector database clients |
elasticsearch opensearch redis mongodb |
search engine and database clients |
langchain llamaindex |
RAG framework integrations |
vault aws gcp azure (or kms for all four) |
key managers |
all |
everything above |
Python 3.10 or newer.
Quick start
This runs as is, with an in-memory Qdrant (examples/quickstart.py):
import numpy as np
from qdrant_client import QdrantClient, models
from hadamask import MasterKey, MetadataPolicy, Protector
from hadamask.adapters.qdrant import QdrantStore
# One key per tenant. "client" can be filtered on, "year" stays in clear,
# every other field ("text", "amount", ...) is encrypted.
protector = Protector(MasterKey.generate("demo1"), MetadataPolicy(plaintext=["year"], blind=["client"]))
client = QdrantClient(":memory:")
store = QdrantStore(client, protector, payload_schema={"year": models.PayloadSchemaType.INTEGER})
store.create_collection("docs", dim=384, metric="cosine") # stored as 512-d
embeddings = np.random.default_rng(0).standard_normal((3, 384)) # use your embedding model
store.upsert(
"docs",
ids=[1, 2, 3],
vectors=embeddings,
payloads=[
{"text": "Wire transfer of 1,250,000 EUR to RO49AAAA1B31007593840000", "client": "ACME", "year": 2026},
{"text": "Loan agreement: principal 480,000 EUR at 4.1% fixed", "client": "ACME", "year": 2025},
{"text": "Quarterly audit report for the treasury department", "client": "Globex", "year": 2026},
],
)
for hit in store.search("docs", embeddings[0], k=2, where={"client": "ACME"}):
print(hit.id, round(hit.score, 3), hit.payload["text"])
What Qdrant stores looks like this (abridged):
payload: {'text': 'hm1:demo1:0Zh9ab4mBNDEKBEwmzkCVnWsvFpnz2...', 'client': 'hm1:demo1:LXXVS92j-4jE...',
'_hmb_client': 'v5z-4r66z7jduraiUjjnKg', 'year': 2026}
vector: [-0.007, 0.037, 0.047, 0.082, -0.035, 0.01, ...] (512 dims)
In production, load the key from a key manager instead of generating it (Key management).
Core concepts
MasterKey
A 256-bit secret plus a public key_id that is stored next to every ciphertext. Every other
secret is derived from it with domain-separated HMAC-SHA256: the transform's signs and
permutation, the AES key, and the blind-token key.
MasterKey.generate("prod-2026") # new random key
MasterKey.from_env() # HADAMASK_MASTER_KEY (base64), HADAMASK_KEY_ID
Keyring.from_wrapped(wrapper, wrapped_string) # unwrapped by Vault / KMS (see below)
Use one key per tenant: different keys give unrelated coordinate systems.
MetadataPolicy
Every payload field falls into one of three classes:
| Class | Declared as | Stored as | Filters |
|---|---|---|---|
| encrypted | default | hm1:<key_id>:<base64url(nonce‖ciphertext‖tag)> |
none |
| blind | blind=[...] |
ciphertext, plus _hmb_<field> token(s) |
$eq $ne $in $nin |
| plaintext | plaintext=[...] |
the value itself | all operators, including ranges |
MetadataPolicy(
plaintext=["year", "department"],
blind=["client_id", "tags"], # list values get one token per element
min_padded_length=64, # every short value pads to 64 bytes
fixed_lengths={"text": 8192}, # this field always pads to exactly 8 KiB
)
Keep blind and plaintext as small as possible. A blind field with few possible values
(status, country) is open to frequency analysis.
Protector
Holds a key and a policy and does all the cryptography. Adapters never see keys.
protector = Protector(key, policy, previous_keys=[old_key])
protector.encode(vectors) # (n, d) -> (n, D) float32; documents and queries alike
protector.decode(vectors, dim=384) # back to native vectors
protector.encrypt_payload(payload) # -> stored dict
protector.decrypt_payload(stored) # -> clear dict (also reads previous keys)
protector.stored_dim(384) # 512: create collections with this size
Choosing a transform
transform="hadamard" (default) |
transform="dense" |
|
|---|---|---|
| Map | H·D2·P·H·D1 (paper, Eq. 3), structured, O(d log d) |
Dense Haar-uniform orthogonal matrix, O(d²) |
| Stored dimension | Next power of two: 384 → 512, 1536 → 2048 | Native: 1536 stays 1536 |
| Storage overhead (1536-d, measured) | +34% | +2–5% |
| pgvector HNSW with 1536-d embeddings | Not possible (pgvector limit: 2000 dims) | Works |
| Client memory | Negligible | 8·d² bytes per dimension: 19 MB for 1536, 75 MB for 3072 |
| Security analysis | Structured key family; the paper's Haar theorems do not apply directly | Exactly the Haar ideal of the paper's Section 3.4 |
protector = Protector(key, policy, transform="dense")
What the two transforms are.
hadamardis the construction from the paper.His the Walsh–Hadamard matrix: a fixed, public matrix of ±1 entries that can be applied inO(d log d). The secret is the sign vectorsD1,D2and the permutationP. That speed comes from structure: only power-of-two sizes work, and every key selects a map from a small, highly structured family.denseis a fulld × dorthogonal matrix with no structure at all. It is drawn uniformly at random among all rotations and reflections ofd-dimensional space: hadamask fills a matrix with Gaussian numbers derived from your key, orthogonalizes it (QR), and fixes the column signs. Mathematicians call that uniform distribution the Haar measure, which is why the paper calls it the Haar ideal. This has nothing to do with the Haar wavelet, and no Hadamard matrix is involved. The same key always gives the same matrix.
Which one to use.
| Your situation | Use |
|---|---|
| Embedding dimension is a power of two (256, 512, 1024, 4096) | hadamard: no padding needed, smallest memory footprint |
| 384, 768, 1536, 3072 or any other dimension | dense: avoids padding (+33% storage for 1536 → 2048) |
| pgvector with HNSW and more than 1024 dimensions | dense (with hadamard, 1536-d vectors become 2048-d, above pgvector's 2000-dimension HNSW limit) |
| You want the paper's formal results for the ideal transform to apply to your data | dense |
| Very large dimensions (8192+) or memory-constrained clients | hadamard (dense keeps a d × d matrix: 537 MB at 8192) |
| You want exactly the construction evaluated experimentally in the paper | hadamard |
Pick once per collection: vectors written with one transform cannot be searched with the
other. To switch, copy_to a new collection whose store uses a Protector with the other
transform.
dense does not make the attacks measured in the paper harder: the geometry leaks the
same way, and d known pairs recover either map. What it changes is that the paper's
results for the ideal transform (a snapshot reveals exactly the Gram matrix and nothing
more) describe your stored data directly, with no structured family left that a future
attack could exploit. It also removes padding.
Both preserve scores exactly, and both leak the same relative geometry (the Gram matrix).
Padding to a larger dimension adds no security: the data still spans a d-dimensional
subspace, so its rank reveals d, and its spectrum is unchanged (paper, Proposition 1). For
1536-d or 3072-d embeddings, or any pgvector deployment, use dense. A collection must be
written and queried with the same transform.
The store API
Every adapter implements the same interface (hadamask.adapters.base.ProtectedStore).
store.create_collection("docs", dim=1536, metric="cosine") # "cosine" | "dot" | "l2"
store.register_collection("docs", dim=1536, metric="cosine") # for collections created elsewhere
store.drop_collection("docs")
Writing
store.upsert("docs", ids, vectors, payloads, batch_size=500) # any size, split into batches
store.update_payload("docs", id, {"status": "closed"}) # merge and re-encrypt, vector kept
store.update_payload("docs", id, new_payload, merge=False) # replace
store.delete("docs", [id1, id2])
store.delete("docs", where={"year": {"$lt": 2020}})
upsert replaces records with the same id. The lengths of ids, vectors and payloads
must match, otherwise it raises instead of silently dropping records.
Searching
hits = store.search(
"docs", query_vector, k=10,
where={"client_id": {"$in": ["C-17", "C-18"]}, "year": {"$gte": 2024}},
offset=10, # paging
score_threshold=0.75, # minimum similarity (maximum distance for l2)
fields=["text", "client_id"],# decrypt only these fields
with_vectors=True, # native vectors on each hit
hnsw_ef=256, # engine-specific options, see below
)
hit.id, hit.score, hit.payload, hit.vector
store.search_many("docs", query_vectors, k=5) # one result list per query
hit.score means the same thing on every database, and equals the value computed on the
clear vectors:
| Metric | score |
|---|---|
cosine |
cosine similarity (higher is closer) |
dot |
inner product (higher is closer) |
l2 |
Euclidean distance, not squared (lower is closer) |
Reading
store.get("docs", ids, with_vectors=True, fields=["year"])
store.count("docs", where={"client_id": "C-17"})
for record in store.scroll("docs", where={"year": 2026}, batch_size=500, with_vectors=True):
...
Filters
MongoDB-style. Several fields in one dict are combined with AND.
| Operator | Example | Plaintext | Blind |
|---|---|---|---|
| equality | {"client_id": "C-17"} or {"$eq": ...} |
✓ | ✓ |
$ne |
{"status": {"$ne": "closed"}} |
✓ | ✓ |
$in, $nin |
{"client_id": {"$in": ["C-17", "C-18"]}} |
✓ | ✓ |
$gt, $gte, $lt, $lte |
{"year": {"$gte": 2024, "$lt": 2027}} |
✓ | — |
$and, $or |
{"$or": [{"client_id": "C-17"}, {"year": 2026}]} |
✓ | ✓ |
- Filters on encrypted-only fields, and ranges on blind fields, raise
ValueErrorbefore anything is sent: the database cannot evaluate them. $neand$ninalso match records that do not have the field, as in MongoDB.- On a list-valued blind field, equality means "the list contains this value".
Migrating and rotating keys
store.copy_to("docs", target_store, "docs_v2", where=None, batch_size=500)
Records are decrypted with this store's key and re-protected with the target's. Use it to move between databases, or to rotate keys (Key rotation).
Supported databases
| Database | Class | Tested against | Search options |
|---|---|---|---|
| Qdrant | QdrantStore |
1.19, Qdrant Cloud | hnsw_ef, exact, other SearchParams |
| Weaviate | WeaviateStore |
1.33, Weaviate Cloud 1.40 | — |
| Milvus | MilvusStore |
2.6 | ef, nprobe, search_list, radius, range_filter, level |
| Chroma | ChromaStore |
1.5 | — |
| pgvector | PgVectorStore |
PostgreSQL 17, pgvector 0.8 | ef_search, iterative_scan |
| Elasticsearch | ElasticsearchStore |
8.17 | num_candidates |
| OpenSearch | OpenSearchStore |
2.19 | num_candidates, ef_search |
| Redis Stack | RedisStore |
7.4 | ef_runtime |
| MongoDB Atlas Vector Search | MongoStore |
8.0 (atlas-local) | num_candidates, exact |
from hadamask.adapters.qdrant import QdrantStore # QdrantStore(client, protector, payload_schema=...)
from hadamask.adapters.weaviate import WeaviateStore # WeaviateStore(client, protector, index_type="hnsw")
from hadamask.adapters.milvus import MilvusStore # MilvusStore(client, protector, index_type="HNSW")
from hadamask.adapters.chroma import ChromaStore # ChromaStore(client, protector)
from hadamask.adapters.pgvector import PgVectorStore # PgVectorStore("postgresql://...", protector, hnsw=True)
from hadamask.adapters.elasticsearch import ElasticsearchStore
from hadamask.adapters.opensearch import OpenSearchStore
from hadamask.adapters.redis import RedisStore # RedisStore(client, protector, numeric_fields=["year"])
from hadamask.adapters.mongodb import MongoStore # MongoStore(client["db"], protector, exact=True)
Every adapter passes the same contract test against a real server. The test covers recall
against exact search, every filter operator checked against reference semantics, counts,
paging, thresholds, projections, scroll, payload updates, copy_to under a second key,
deletion by filter, and a check that only ciphertext reaches the database. A second test
checks that score equals the clear value for all three metrics on all nine engines. In
these tests, recall@10 is 1.0 on every engine.
Per-database notes, including index choices, quantization, and limits such as Redis ranges
needing numeric_fields, are in docs/databases.md.
SDK wrappers for Qdrant and Weaviate
If your code already uses the official SDKs, the wrappers keep the same method names.
from hadamask.adapters.qdrant import ProtectedQdrantClient
qdrant = ProtectedQdrantClient(QdrantClient(url), protector)
qdrant.create_collection("docs", vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE))
qdrant.upsert("docs", points=[models.PointStruct(id=1, vector=embedding, payload={"text": "...", "client": "ACME"})])
qdrant.query_points("docs", query=query_embedding, limit=5,
query_filter=models.Filter(must=[qdrant.match("client", "ACME")]))
from hadamask.adapters.weaviate import ProtectedWeaviateCollection
docs = ProtectedWeaviateCollection(client.collections.get("Docs"), protector, dim=1536)
docs.data.insert({"text": "...", "client": "ACME"}, vector=embedding)
docs.query.near_vector(query_embedding, limit=5, filters=docs.match("client", "ACME"))
Methods that would read or write data without protection (set_payload, upload_points,
batch, query.hybrid, prefetch, ...) raise instead of passing through. .unprotected
gives explicit access to the raw client.
LangChain and LlamaIndex
One wrapper covers every vector store these frameworks support.
from hadamask.integrations.langchain import ProtectedVectorStore
from langchain_qdrant import QdrantVectorStore
store = ProtectedVectorStore(
build_inner=lambda emb: QdrantVectorStore(client, "docs", embedding=emb),
embedding=OpenAIEmbeddings(), # your real embeddings
protector=protector,
)
store.add_texts(texts, metadatas=metadatas) # page_content and metadata encrypted
retriever = store.as_retriever(search_kwargs={"k": 5, "filter": {"client": "ACME"}})
from hadamask.integrations.llamaindex import ProtectedVectorStore
from llama_index.core import StorageContext, VectorStoreIndex
from llama_index.vector_stores.qdrant import QdrantVectorStore
store = ProtectedVectorStore(QdrantVectorStore(client=client, collection_name="docs"), protector, embed_dim=1536)
index = VectorStoreIndex.from_documents(docs, storage_context=StorageContext.from_defaults(vector_store=store))
Both protect vectors, text, metadata, and (in LlamaIndex) relationship metadata, which
LlamaIndex otherwise copies in clear. Clear query text is never sent to the database. If
you only want vector protection, wrap your embedding model instead:
ProtectedEmbeddings (LangChain) or ProtectedEmbedding (LlamaIndex). Never combine the
two, or the vectors are transformed twice.
Key management
The master key can stay wrapped by a key manager (envelope encryption). Only the wrapped string goes into configuration, and the clear key exists only in your application's memory.
hadamask keygen --key-id prod-2026 --wrap aws-kms --aws-kms-key-id alias/hadamask --env
# HADAMASK_WRAPPED_KEY=hmk1:aws-kms:prod-2026:AQICAHh...
import os
from hadamask.kms import Keyring
from hadamask.kms.aws import AwsKmsKeyWrapper
keyring = Keyring.from_wrapped(AwsKmsKeyWrapper("alias/hadamask"), os.environ["HADAMASK_WRAPPED_KEY"])
protector = keyring.protector(MetadataPolicy(blind=["client_id"]))
| Provider | Wrapper | Extra |
|---|---|---|
| HashiCorp Vault Transit | hadamask.kms.vault.VaultTransitKeyWrapper(client, key_name) |
vault |
| HashiCorp Vault KV v2 (key stored as a secret) | hadamask.kms.vault.VaultKVKeyStore(client, path) |
vault |
| AWS KMS | hadamask.kms.aws.AwsKmsKeyWrapper(key_id) |
aws |
| Google Cloud KMS | hadamask.kms.gcp.GcpKmsKeyWrapper(key_name) |
gcp |
| Azure Key Vault / Managed HSM | hadamask.kms.azure.AzureKeyVaultKeyWrapper(key_url) |
azure |
Wrapped keys are bound to their key_id: relabelling a wrapped key, or using it with
another provider, fails. The application needs only encrypt/decrypt permission on one
key. Setup, minimum permissions and policies for each provider are in
docs/key-management.md.
Key rotation
- Create a new key, for example
prod-2027. - Build a protector that writes with the new key and still reads the old one:
Keyring(current=new, previous=(old,)).protector(policy). - Copy each collection to one protected with the new key, then switch reads to it:
old_store.copy_to("docs", new_store, "docs_2027"). This rewrites vectors, ciphertexts and blind tokens.
Rotation cannot erase what an earlier snapshot already exposed, and persistent ids link old
and new snapshots (paper, Section 6.2). Without moving records, vectors can also be
re-mapped with OrthogonalTransform.rekey_to (Q_new·Q_oldᵀ).
Command line
hadamask keygen [--key-id ID] [--env] # new raw key (store it in a secret manager)
hadamask keygen --wrap {local,vault-transit,aws-kms,gcp-kms,azure-kv} [provider options] [--env]
hadamask info 1536 # 1536 -> stored dimension 2048
Design
| Piece | Choice |
|---|---|
| Transform | Q = H·D2·P·H·D1 (paper, Eq. 3). H is the normalized Walsh–Hadamard transform (FWHT, O(D log D)), D1/D2 are sign vectors, P is a permutation |
| Randomness | HMAC-SHA256 streams of the master key with separate labels; Fisher–Yates with rejection sampling for P |
| Dimensions | Native d is zero-padded to the next power of two D; queries are padded the same way |
| Dense transform | transform="dense": sign-corrected QR of a Gaussian matrix drawn from a ChaCha20 keystream under a derived key; exactly Haar-uniform, no padding |
| Metadata | AES-256-GCM, 96-bit random nonce per value; associated data = key id and field name |
| Length hiding | ISO/IEC 7816-4 padding to min_padded_length (64 bytes), then powers of two, or a fixed size per field |
| Blind tokens | HMAC-SHA256 under a derived key, truncated to 128 bits |
| Wrapped keys | hmk1:<provider>:<key_id>:<blob>; payload bound to hadamask/master-key/v1/<key_id>, also passed as AAD where the KMS supports it |
| Validation | Non-finite vectors, mismatched batch lengths, unknown filter operators and unknown search options all raise |
Optional noise padding (Protector.transform(d).encode(docs, noise_norm=...), paper
Section 3.5.5) is available for dot-product collections only.
Approximate indexes return the same sets as clear search, up to the engine's own ANN and
float32 behaviour (paper, Section 5.7). Some engines quantize by default (Elasticsearch
int8_hnsw); the adapters choose unquantized indexes explicitly.
Benchmarks
100,000 real documents (DBpedia, OpenAI ada-002 embeddings, 1536-d) and 1,000 held-out
queries. Each row compares a hadamask-protected collection with an unprotected one on the
same database, with the same HNSW settings (m=16, ef=128), the same data, and one
single-threaded client measuring end-to-end latency. Full tables, including p95/p99, ingest
time and memory, are in benchmarks/results/RESULTS.md.
| Qdrant, unprotected | Qdrant, hadamask dense |
pgvector, unprotected | pgvector, hadamask dense |
|
|---|---|---|---|---|
| Recall@10 vs exact search | 0.991 | 0.991 | 0.995 | 0.995 |
| Query latency p50 | 3.63 ms | 4.01 ms (1.11×) | 5.69 ms | 6.34 ms (1.11×) |
| Throughput, one client | 260 q/s | 235 q/s (0.91×) | 178 q/s | 154 q/s (0.86×) |
| Storage per 1M vectors | 13.2 GB | 13.8 GB (1.05×) | 17.0 GB | 17.4 GB (1.02×) |
| Server CPU per 1M queries | 2,338 CPU-s | 2,245 CPU-s (0.96×) | 4,218 CPU-s | 4,466 CPU-s (1.06×) |
With the default hadamard transform, the 2048-d stored vectors cost +34% storage on
Qdrant. On pgvector they cannot be HNSW-indexed at all, so search falls back to an exact
scan: 212 ms instead of 5.7 ms. On the client, the dense transform runs at about 77,000
vectors/s in batches (0.17 ms for a single query). Encrypting or decrypting a payload
(title and text) takes 5–7 µs; padding and the envelope grow the mean payload from 404 to
838 bytes.
Caveats: one machine (Apple Silicon laptop, databases in Docker); one run per
configuration; HNSW graphs built from different coordinates differ slightly, so
protected and unprotected top-10 sets overlap at 0.99 rather than 1.00, with the same
recall. Reproduce with python benchmarks/run.py (instructions).
Testing
docker compose up -d # 9 databases + Vault, LocalStack (KMS), Lowkey Vault (Azure)
pip install -e ".[dev]"
pytest tests/test_core.py # unit tests, no services
pytest -m integration # every database and key manager
pytest -m cloud # Qdrant Cloud and Weaviate Cloud (QDRANT_CLOUD_URL, ... in env)
Roadmap
- Hybrid search: protected BM25 / sparse vectors
- Named and multiple vectors per record in the store API
- Async API
- Reproducible leakage benchmarks on real embeddings (attack demos)
- Benchmarks for the other seven databases, repeated runs
- External security review
Citation
@article{cernica2026secretrotation,
title = {What a Secret Rotation Buys: Exact Retrieval and Measured Leakage in Vector Databases},
author = {Cernica, Ionu{\c{t}} and Popescu, Nirvana},
journal = {Applied Sciences},
volume = {16},
number = {20},
pages = {10259},
year = {2026},
doi = {10.3390/app162010259}
}
License
Apache-2.0. Security issues: see SECURITY.md.
Metadata
Release files for hadamask 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hadamask-0.1.1.tar.gz | 108.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hadamask-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 194.9 kB
Release files / hadamask-0.1.1.tar.gz
| Download URL | hadamask-0.1.1.tar.gz |
|---|---|
| Size | 108.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
718d18829eabfc326e072f05f52c88cb42c33796a97eb2d559eff1f02efde53e
|
|
BLAKE2b-256 checksum How to use checksums |
8ff65804a3ea84ae57fc54515bf2be3b9dcc74fd67543a11bb3a2cff9d79c6ac
|
| 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 17, 2026.
Transparency logRelease files / hadamask-0.1.1-py3-none-any.whl
| Download URL | hadamask-0.1.1-py3-none-any.whl |
|---|---|
| Size | 86.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4af190b406f0978bd0d937da66242517ae5a7eb93ae99b365b6a055f8a950679
|
|
BLAKE2b-256 checksum How to use checksums |
f0a81b24b24d01ecf6b56f0737dcfa5f00b203fde86834f0a2cdb762603150d0
|
| 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 17, 2026.
Transparency log