Per-user, per-service envelope encryption (AES-256-GCM + HKDF) for BYOK API-key storage
Project description
byok-vault
Per-user, per-service envelope encryption for BYOK ("bring your own key") secrets — AES-256-GCM sealing with HKDF-SHA256 key derivation, so one injected master key protects an unbounded family of scoped secrets.
- One master key, many scoped keys. A per-encryption key is derived from
(master_key, scope)via HKDF-SHA256. A secret sealed for(user_42, "openai")cannot be opened under any other scope, even though every scope shares the same master key. - Authenticated. AES-256-GCM. The scope is bound in as additional authenticated data (AAD), so a moved or tampered ciphertext fails loudly instead of decrypting to garbage.
- Injection-proof scopes. Scope parts are length-prefixed before hashing, so
("a", "bc")and("ab", "c")are always distinct keys — no separator-injection ambiguity. - You own storage.
encryptreturnsbytes,decrypttakesbytes. Where those bytes live — a Postgresbyteacolumn, a file, a KMS blob — is up to you. Storage is your problem, bring your own. - One dependency:
cryptography.
Install
pip install byok-vault
Requires Python 3.10+.
Quickstart
from byok_vault import Vault
# The master key is a 32-byte secret you inject (env var, secrets manager, KMS).
vault = Vault.from_env("VAULT_MASTER_KEY") # 64 hex chars, or a passphrase
# Encrypt a user's OpenAI key. The scope tuple is arbitrary — here (user_id, provider).
blob = vault.encrypt("sk-live-abc123", 42, "openai") # -> bytes, store as you like
# Later, decrypt it back. The same scope is required.
secret = vault.decrypt_str(blob, 42, "openai") # -> "sk-live-abc123"
# Wrong scope? It raises, it does not return a wrong value.
vault.decrypt_str(blob, 42, "anthropic") # DecryptionError
Construct directly if you already hold the key bytes:
import os
from byok_vault import Vault
vault = Vault(os.urandom(32)) # 32 raw bytes
vault = Vault("a3f1...64-hex-chars") # or a 64-hex-char string
API
| Member | Signature | Description |
|---|---|---|
Vault |
Vault(master_key: bytes | str, *, min_key_bytes=16) |
Hold a master key. 32 raw bytes (or 64 hex chars) are used directly as the AES-256 key; any other input of at least min_key_bytes is HKDF-stretched to 32. |
Vault.from_env |
Vault.from_env(var="VAULT_MASTER_KEY", *, min_key_bytes=16) |
Build from an env var. Raises InvalidMasterKey if unset/empty — it never silently generates a key. |
encrypt |
encrypt(plaintext: str | bytes, *scope) -> bytes |
Seal plaintext for scope. Returns nonce ‖ ciphertext ‖ tag. Empty plaintext → b"". |
decrypt |
decrypt(blob: bytes | None, *scope) -> bytes |
Open a blob for scope. Empty/None blob → b"". Tampered/truncated/wrong-scope → DecryptionError. |
decrypt_str |
decrypt_str(blob, *scope) -> str |
decrypt then UTF-8 decode. |
health_check |
health_check() -> dict |
Round-trip self-test. Returns {"ok", "roundtrip", "algorithm", "kdf", "nonce_bytes"}. Never contains key material. |
Exceptions: VaultError (base) → InvalidMasterKey (also a ValueError) and DecryptionError.
Envelope format
Every ciphertext is a single opaque byte string with this fixed layout:
┌───────────────┬──────────────────────────┬──────────────┐
│ nonce (12 B) │ ciphertext │ tag (16 B) │
└───────────────┴──────────────────────────┴──────────────┘
random AES-256-GCM output GCM auth tag
| Field | Size | Notes |
|---|---|---|
| Nonce | 12 bytes | Fresh 96-bit secrets.token_bytes(12) per encryption. The 12-byte size is what AES-GCM is specified for. |
| Ciphertext | = plaintext length | AES-256-GCM. |
| Tag | 16 bytes | GCM authentication tag, appended to the ciphertext by cryptography. |
An empty plaintext encrypts to an empty blob (b"") and an empty blob decrypts back to b"" — so a "no value stored" slot round-trips without a special case.
Key derivation
For each (scope) the AES-256 key is HKDF-SHA256(master_key) with the salt and info bytes below, and the same scope bytes are used as the AES-GCM AAD.
Generalized scope (any arity). The scope tuple is serialized unambiguously — a version tag, the part count, then each part as len(4-byte big-endian) ‖ utf8(str(part)):
info = b"byok-vault/scope/v1\x00" + count + Σ (len32(part) + part)
salt = b"byok-vault/hkdf/scope/v1"
aad = info
Length-prefixing is what makes scopes injection-proof: ("a", "bc"), ("ab", "c"), and ("a:b",) all serialize differently, so none can ever collapse onto another's key.
Legacy 2-tuple (int user_id, api_id). When called with exactly two parts whose first is a plain int, byok-vault reproduces the derivation of the platform code it was extracted from, byte for byte, so ciphertexts written by that code decrypt here unchanged:
salt = utf8(str(int(user_id)))
info = utf8("api:" + api_id)
aad = utf8("user={user_id}&api={api_id}")
This 2-tuple form is safe on its own — each variable part lives in its own HKDF field (user in the salt, api in the info) and the first part is integer-typed, so there is no cross-part concatenation to be ambiguous about. Any scope shape other than (int, …) × 2 uses the length-prefixed encoding above. A legacy (1, "svc") scope and a canonical ("1", "svc") scope are therefore deliberately different keys.
Master-key stretching. A 32-byte key is used directly. Anything else (of at least min_key_bytes) is expanded to 32 bytes with HKDF-SHA256(salt=b"rpg-master-stretch", info=b"v1") — those constants are retained verbatim for compatibility with keys stretched by the original code. Stretching reshapes key material to the AES-256 size; it does not add entropy, so your master key must itself be a high-entropy secret.
Security notes
- Nonces are random, 96-bit, one per encryption. For a given derived key, keep well under ~2³² encryptions to stay clear of the birthday bound on random nonces. Because a fresh key is derived per scope, that budget is per scope — typically a handful of encryptions per
(user, service)— so this is comfortable for API-key storage. If you expect to re-encrypt a single scope billions of times, rotate the master key or add a counter to the scope. - Tampering raises, it never returns empty. A non-empty blob that is truncated, bit-flipped, or presented under the wrong scope raises
DecryptionError. Only a genuinely empty/Noneblob yieldsb"". - Errors carry no secrets. No exception message includes the master key, a derived key, or the plaintext.
DecryptionErroris deliberately generic so it can't become an oracle. - The master key is a root secret. Anyone with the master key and the ciphertext can decrypt — that is the threat model of envelope encryption. Keep the master key off the same disk as the ciphertext (inject it from a secrets manager or KMS). This library never writes the key anywhere and never auto-generates one.
Storage is your problem
byok-vault does no I/O. It does not touch a database, a file, or a network. encrypt gives you bytes; persist them however you like (e.g. a Postgres bytea column keyed by (user_id, service)), and hand them back to decrypt. Bring your own storage.
License
MIT — see LICENSE.
Acknowledgements
Extracted from the RPG Roleplay platform (https://github.com/felixchaos/rpg-roleplay-platform).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file byok_vault-0.1.0.tar.gz.
File metadata
- Download URL: byok_vault-0.1.0.tar.gz
- Upload date:
- Size: 17.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
44659b2df449dac07c14f366a1cfe75abd0d1907f908514af9cea4aec19f9e27
|
|
| MD5 |
cdc30b23c20992ae4af6668e3a71ffe0
|
|
| BLAKE2b-256 |
2752a0d61727fd996bfe3b6e0939f4971c732a87702dbc2d371182ae6e61dd4c
|
Provenance
The following attestation bundles were made for byok_vault-0.1.0.tar.gz:
Publisher:
release.yml on felixchaos/byok-vault
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
byok_vault-0.1.0.tar.gz -
Subject digest:
44659b2df449dac07c14f366a1cfe75abd0d1907f908514af9cea4aec19f9e27 - Sigstore transparency entry: 2168011935
- Sigstore integration time:
-
Permalink:
felixchaos/byok-vault@eefbc8adc588c782bd3f5a7aaf6886476fabb8ef -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/felixchaos
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@eefbc8adc588c782bd3f5a7aaf6886476fabb8ef -
Trigger Event:
push
-
Statement type:
File details
Details for the file byok_vault-0.1.0-py3-none-any.whl.
File metadata
- Download URL: byok_vault-0.1.0-py3-none-any.whl
- Upload date:
- Size: 11.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ab3d9b0a73988339c08c572c34840fc6938cc7abfc4b99288ec911d5ce105c8
|
|
| MD5 |
73277926f20cdda88cffde895e976ceb
|
|
| BLAKE2b-256 |
3a6da5337420bdcc9dc52a2094a6aecedf2e367b404b9d391f38bd7a233de66d
|
Provenance
The following attestation bundles were made for byok_vault-0.1.0-py3-none-any.whl:
Publisher:
release.yml on felixchaos/byok-vault
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
byok_vault-0.1.0-py3-none-any.whl -
Subject digest:
2ab3d9b0a73988339c08c572c34840fc6938cc7abfc4b99288ec911d5ce105c8 - Sigstore transparency entry: 2168011948
- Sigstore integration time:
-
Permalink:
felixchaos/byok-vault@eefbc8adc588c782bd3f5a7aaf6886476fabb8ef -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/felixchaos
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@eefbc8adc588c782bd3f5a7aaf6886476fabb8ef -
Trigger Event:
push
-
Statement type: