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).
Metadata
Release files for byok-vault 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 | Provenance | Actions |
|---|---|---|---|---|
| byok_vault-0.1.0.tar.gz | 17.6 kB | With provenance | Details for byok_vault-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| byok_vault-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for byok_vault-0.1.0-py3-none-any.whl |
Total release size: 29.0 kB
Release files / byok_vault-0.1.0.tar.gz
| Download URL | byok_vault-0.1.0.tar.gz |
|---|---|
| Size | 17.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
44659b2df449dac07c14f366a1cfe75abd0d1907f908514af9cea4aec19f9e27
|
|
BLAKE2b-256 checksum How to use checksums |
2752a0d61727fd996bfe3b6e0939f4971c732a87702dbc2d371182ae6e61dd4c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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 Jul 14, 2026.
Transparency logRelease files / byok_vault-0.1.0-py3-none-any.whl
| Download URL | byok_vault-0.1.0-py3-none-any.whl |
|---|---|
| Size | 11.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ab3d9b0a73988339c08c572c34840fc6938cc7abfc4b99288ec911d5ce105c8
|
|
BLAKE2b-256 checksum How to use checksums |
3a6da5337420bdcc9dc52a2094a6aecedf2e367b404b9d391f38bd7a233de66d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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 Jul 14, 2026.
Transparency logSecurity for byok-vault 0.1.0
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for byok_vault-0.1.0.tar.gz
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 Jul 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.