Skip to content

Commit 44375f8

Browse files
feat: v0.12.0 — post-quantum crypto, input hardening, plugin security (#11)
DELIVERED (not removed): - ML-KEM-768 key encapsulation (FIPS 203) via liboqs-python - ML-DSA-65 digital signatures (FIPS 204) via liboqs-python - HybridEncryptor: ML-KEM-768 + AES-256-GCM combined - [pq] installation extra HARDENED: - SearchRequest validators (top_k max 1000, threshold 0-1, query max 10K) - Batch endpoint limited to 100 items - Plugin hash verification via manifest.json + SHA3-256 - Tenant-locked vault mode (tenant_id in constructor) 518 tests. Lint clean. Build verified.
1 parent 4586004 commit 44375f8

File tree

10 files changed

+327
-9
lines changed

10 files changed

+327
-9
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.12.0] - 2026-04-06
11+
12+
### Added
13+
- **Post-quantum cryptography (delivered)**:
14+
- `MLKEMKeyManager` — ML-KEM-768 key encapsulation (FIPS 203)
15+
- `MLDSASigner` — ML-DSA-65 digital signatures (FIPS 204)
16+
- `HybridEncryptor` — ML-KEM-768 + AES-256-GCM hybrid encryption
17+
- `[pq]` installation extra: `pip install qp-vault[pq]`
18+
- **Input bounds**: `top_k` capped at 1000, `threshold` range 0-1, query max 10K chars
19+
- **Batch limits**: max 100 items per `/batch` request
20+
- **Plugin hash verification**: `manifest.json` with SHA3-256 hashes in plugins_dir
21+
- **Tenant-locked vault**: `Vault(path, tenant_id="x")` enforces single-tenant scope
22+
23+
### Security
24+
- SearchRequest Pydantic validators prevent unbounded parameter attacks
25+
- Plugin files verified against manifest before execution
26+
1027
## [0.11.0] - 2026-04-06
1128

1229
### Added

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qp-vault"
7-
version = "0.11.0"
7+
version = "0.12.0"
88
description = "Governed knowledge store for autonomous organizations. Trust tiers, cryptographic audit trails, content-addressed storage, air-gap native."
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -68,6 +68,9 @@ encryption = [
6868
"cryptography>=42",
6969
"pynacl>=1.6.2",
7070
]
71+
pq = [
72+
"liboqs-python>=0.14.1",
73+
]
7174
integrity = [
7275
"numpy>=2.0",
7376
]
@@ -87,7 +90,7 @@ dev = [
8790
"ruff>=0.9",
8891
]
8992
all = [
90-
"qp-vault[sqlite,postgres,docling,local,openai,capsule,encryption,integrity,fastapi,cli]",
93+
"qp-vault[sqlite,postgres,docling,local,openai,capsule,encryption,pq,integrity,fastapi,cli]",
9194
]
9295

9396
[project.scripts]

src/qp_vault/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Docs: https://github.com/quantumpipes/vault
2727
"""
2828

29-
__version__ = "0.11.0"
29+
__version__ = "0.12.0"
3030
__author__ = "Quantum Pipes Technologies, LLC"
3131
__license__ = "Apache-2.0"
3232

src/qp_vault/encryption/__init__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,26 @@
33

44
"""Encryption at rest for qp-vault.
55
6-
Provides AES-256-GCM symmetric encryption for chunk content.
7-
Requires: pip install qp-vault[encryption]
6+
Classical: AES-256-GCM (FIPS 197)
7+
Post-quantum: ML-KEM-768 key encapsulation (FIPS 203) + ML-DSA-65 signatures (FIPS 204)
8+
Hybrid: ML-KEM-768 + AES-256-GCM (quantum-resistant data encryption)
9+
10+
Install:
11+
pip install qp-vault[encryption] # AES-256-GCM only
12+
pip install qp-vault[pq] # + ML-KEM-768 + ML-DSA-65
13+
pip install qp-vault[encryption,pq] # Full hybrid encryption
814
"""
915

1016
from qp_vault.encryption.aes_gcm import AESGCMEncryptor
1117

1218
__all__ = ["AESGCMEncryptor"]
19+
20+
# Conditional PQ exports (available when liboqs-python installed)
21+
try:
22+
from qp_vault.encryption.hybrid import HybridEncryptor
23+
from qp_vault.encryption.ml_dsa import MLDSASigner
24+
from qp_vault.encryption.ml_kem import MLKEMKeyManager
25+
26+
__all__ += ["MLKEMKeyManager", "MLDSASigner", "HybridEncryptor"] # type: ignore[assignment]
27+
except ImportError:
28+
pass

src/qp_vault/encryption/hybrid.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Copyright 2026 Quantum Pipes Technologies, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Hybrid encryption: ML-KEM-768 key exchange + AES-256-GCM data encryption.
5+
6+
Combines post-quantum key encapsulation (FIPS 203) with classical
7+
symmetric encryption (FIPS 197) for defense-in-depth:
8+
9+
1. ML-KEM-768 encapsulates a shared secret (32 bytes)
10+
2. Shared secret is used as AES-256-GCM key
11+
3. Data is encrypted with AES-256-GCM
12+
13+
Format: kem_ciphertext_len (4 bytes) || kem_ciphertext || aes_nonce (12) || aes_ciphertext || aes_tag (16)
14+
15+
Requires: pip install qp-vault[pq,encryption]
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import struct
21+
22+
from qp_vault.encryption.aes_gcm import AESGCMEncryptor
23+
from qp_vault.encryption.ml_kem import MLKEMKeyManager
24+
25+
26+
class HybridEncryptor:
27+
"""ML-KEM-768 + AES-256-GCM hybrid encryption.
28+
29+
Provides quantum-resistant data encryption by wrapping AES keys
30+
with ML-KEM-768 key encapsulation.
31+
32+
Usage:
33+
enc = HybridEncryptor()
34+
pub, sec = enc.generate_keypair()
35+
ciphertext = enc.encrypt(b"secret data", pub)
36+
plaintext = enc.decrypt(ciphertext, sec)
37+
"""
38+
39+
def __init__(self) -> None:
40+
self._kem = MLKEMKeyManager()
41+
42+
def generate_keypair(self) -> tuple[bytes, bytes]:
43+
"""Generate an ML-KEM-768 keypair for hybrid encryption.
44+
45+
Returns:
46+
(public_key, secret_key) — store secret_key securely.
47+
"""
48+
return self._kem.generate_keypair()
49+
50+
def encrypt(self, plaintext: bytes, public_key: bytes) -> bytes:
51+
"""Encrypt data with hybrid ML-KEM-768 + AES-256-GCM.
52+
53+
Args:
54+
plaintext: Data to encrypt.
55+
public_key: ML-KEM-768 public key.
56+
57+
Returns:
58+
Hybrid ciphertext: kem_ct_len(4) || kem_ct || aes_encrypted
59+
"""
60+
# Step 1: ML-KEM-768 key encapsulation -> shared secret (32 bytes)
61+
kem_ciphertext, shared_secret = self._kem.encapsulate(public_key)
62+
63+
# Step 2: AES-256-GCM encrypt with the shared secret as key
64+
aes = AESGCMEncryptor(key=shared_secret[:32])
65+
aes_encrypted = aes.encrypt(plaintext)
66+
67+
# Step 3: Pack: kem_ct_len || kem_ct || aes_encrypted
68+
return struct.pack(">I", len(kem_ciphertext)) + kem_ciphertext + aes_encrypted
69+
70+
def decrypt(self, data: bytes, secret_key: bytes) -> bytes:
71+
"""Decrypt hybrid ML-KEM-768 + AES-256-GCM ciphertext.
72+
73+
Args:
74+
data: Hybrid ciphertext from encrypt().
75+
secret_key: ML-KEM-768 secret key.
76+
77+
Returns:
78+
Decrypted plaintext.
79+
"""
80+
# Step 1: Unpack kem_ct_len
81+
if len(data) < 4:
82+
raise ValueError("Hybrid ciphertext too short")
83+
kem_ct_len = struct.unpack(">I", data[:4])[0]
84+
85+
# Step 2: Extract KEM ciphertext and AES ciphertext
86+
kem_ciphertext = data[4 : 4 + kem_ct_len]
87+
aes_encrypted = data[4 + kem_ct_len :]
88+
89+
# Step 3: ML-KEM-768 decapsulation -> shared secret
90+
shared_secret = self._kem.decapsulate(kem_ciphertext, secret_key)
91+
92+
# Step 4: AES-256-GCM decrypt
93+
aes = AESGCMEncryptor(key=shared_secret[:32])
94+
return aes.decrypt(aes_encrypted)

src/qp_vault/encryption/ml_dsa.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright 2026 Quantum Pipes Technologies, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""ML-DSA-65 post-quantum signatures for provenance attestation.
5+
6+
Signs and verifies data using ML-DSA-65 (FIPS 204), providing
7+
quantum-resistant digital signatures for provenance records,
8+
Merkle proofs, and audit attestations.
9+
10+
Requires: pip install qp-vault[pq]
11+
"""
12+
13+
from __future__ import annotations
14+
15+
try:
16+
import oqs
17+
HAS_OQS = True
18+
except ImportError:
19+
HAS_OQS = False
20+
21+
22+
class MLDSASigner:
23+
"""ML-DSA-65 digital signature manager (FIPS 204).
24+
25+
Generates keypairs, signs data, and verifies signatures.
26+
Used for provenance attestation and audit record signing.
27+
28+
Usage:
29+
signer = MLDSASigner()
30+
pub, sec = signer.generate_keypair()
31+
signature = signer.sign(b"data", sec)
32+
assert signer.verify(b"data", signature, pub)
33+
"""
34+
35+
ALGORITHM = "ML-DSA-65"
36+
37+
def __init__(self) -> None:
38+
if not HAS_OQS:
39+
raise ImportError(
40+
"liboqs-python is required for ML-DSA-65. "
41+
"Install with: pip install qp-vault[pq]"
42+
)
43+
44+
def generate_keypair(self) -> tuple[bytes, bytes]:
45+
"""Generate an ML-DSA-65 keypair.
46+
47+
Returns:
48+
(public_key, secret_key) as bytes.
49+
"""
50+
sig = oqs.Signature(self.ALGORITHM)
51+
public_key = sig.generate_keypair()
52+
secret_key = sig.export_secret_key()
53+
return public_key, secret_key
54+
55+
def sign(self, message: bytes, secret_key: bytes) -> bytes:
56+
"""Sign a message with ML-DSA-65.
57+
58+
Args:
59+
message: The data to sign.
60+
secret_key: ML-DSA-65 secret key.
61+
62+
Returns:
63+
The signature bytes.
64+
"""
65+
sig = oqs.Signature(self.ALGORITHM, secret_key=secret_key)
66+
return sig.sign(message)
67+
68+
def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool:
69+
"""Verify an ML-DSA-65 signature.
70+
71+
Args:
72+
message: The original signed data.
73+
signature: The signature to verify.
74+
public_key: ML-DSA-65 public key.
75+
76+
Returns:
77+
True if signature is valid.
78+
"""
79+
sig = oqs.Signature(self.ALGORITHM)
80+
return sig.verify(message, signature, public_key)

src/qp_vault/encryption/ml_kem.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright 2026 Quantum Pipes Technologies, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""ML-KEM-768 key encapsulation for post-quantum key exchange.
5+
6+
Wraps AES-256-GCM data encryption keys (DEK) with ML-KEM-768 (FIPS 203).
7+
The encapsulated key can only be decapsulated by the holder of the
8+
ML-KEM-768 secret key, providing quantum-resistant key protection.
9+
10+
Requires: pip install qp-vault[pq]
11+
"""
12+
13+
from __future__ import annotations
14+
15+
try:
16+
import oqs
17+
HAS_OQS = True
18+
except ImportError:
19+
HAS_OQS = False
20+
21+
22+
class MLKEMKeyManager:
23+
"""ML-KEM-768 key encapsulation manager (FIPS 203).
24+
25+
Generates keypairs, encapsulates shared secrets, and decapsulates
26+
them. Used to wrap AES-256-GCM keys for post-quantum protection.
27+
28+
Usage:
29+
km = MLKEMKeyManager()
30+
pub, sec = km.generate_keypair()
31+
ciphertext, shared_secret = km.encapsulate(pub)
32+
recovered = km.decapsulate(ciphertext, sec)
33+
assert shared_secret == recovered
34+
"""
35+
36+
ALGORITHM = "ML-KEM-768"
37+
38+
def __init__(self) -> None:
39+
if not HAS_OQS:
40+
raise ImportError(
41+
"liboqs-python is required for ML-KEM-768. "
42+
"Install with: pip install qp-vault[pq]"
43+
)
44+
45+
def generate_keypair(self) -> tuple[bytes, bytes]:
46+
"""Generate an ML-KEM-768 keypair.
47+
48+
Returns:
49+
(public_key, secret_key) as bytes.
50+
"""
51+
kem = oqs.KeyEncapsulation(self.ALGORITHM)
52+
public_key = kem.generate_keypair()
53+
secret_key = kem.export_secret_key()
54+
return public_key, secret_key
55+
56+
def encapsulate(self, public_key: bytes) -> tuple[bytes, bytes]:
57+
"""Encapsulate a shared secret using a public key.
58+
59+
Args:
60+
public_key: ML-KEM-768 public key.
61+
62+
Returns:
63+
(ciphertext, shared_secret) — ciphertext is sent to key holder,
64+
shared_secret is used as AES-256-GCM key.
65+
"""
66+
kem = oqs.KeyEncapsulation(self.ALGORITHM)
67+
ciphertext, shared_secret = kem.encap_secret(public_key)
68+
return ciphertext, shared_secret
69+
70+
def decapsulate(self, ciphertext: bytes, secret_key: bytes) -> bytes:
71+
"""Decapsulate a shared secret using the secret key.
72+
73+
Args:
74+
ciphertext: The encapsulated ciphertext from encapsulate().
75+
secret_key: ML-KEM-768 secret key.
76+
77+
Returns:
78+
The shared secret (same as returned by encapsulate).
79+
"""
80+
kem = oqs.KeyEncapsulation(self.ALGORITHM, secret_key=secret_key)
81+
return kem.decap_secret(ciphertext)

src/qp_vault/integrations/fastapi_routes.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ class AddResourceRequest(BaseModel):
4848
lifecycle: str = "active"
4949

5050
class SearchRequest(BaseModel):
51-
query: str
52-
top_k: int = 10
53-
threshold: float = 0.0
51+
query: str = Field(..., max_length=10000)
52+
top_k: int = Field(10, ge=1, le=1000)
53+
threshold: float = Field(0.0, ge=0.0, le=1.0)
5454
trust_min: str | None = None
5555
layer: str | None = None
5656
collection: str | None = None
@@ -293,6 +293,8 @@ async def search_faceted(req: SearchRequest) -> dict[str, Any]:
293293
@router.post("/batch")
294294
async def add_batch(req: dict[str, Any]) -> dict[str, Any]:
295295
sources = req.get("sources", [])
296+
if len(sources) > 100:
297+
raise HTTPException(status_code=400, detail="Batch limited to 100 items")
296298
trust = req.get("trust", "working")
297299
tenant_id = req.get("tenant_id")
298300
resources = await vault.add_batch(

0 commit comments

Comments
 (0)