Skip to main content

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. encrypt returns bytes, decrypt takes bytes. Where those bytes live — a Postgres bytea column, 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/None blob yields b"".
  • Errors carry no secrets. No exception message includes the master key, a derived key, or the plaintext. DecryptionError is 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)

Source distribution for byok-vault 0.1.0
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)

Table of built distributions (wheels) for byok-vault 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page