Skip to main content

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

byok_vault-0.1.0.tar.gz (17.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

byok_vault-0.1.0-py3-none-any.whl (11.4 kB view details)

Uploaded Python 3

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

Hashes for byok_vault-0.1.0.tar.gz
Algorithm Hash digest
SHA256 44659b2df449dac07c14f366a1cfe75abd0d1907f908514af9cea4aec19f9e27
MD5 cdc30b23c20992ae4af6668e3a71ffe0
BLAKE2b-256 2752a0d61727fd996bfe3b6e0939f4971c732a87702dbc2d371182ae6e61dd4c

See more details on using hashes here.

Provenance

The following attestation bundles were made for byok_vault-0.1.0.tar.gz:

Publisher: release.yml on felixchaos/byok-vault

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for byok_vault-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2ab3d9b0a73988339c08c572c34840fc6938cc7abfc4b99288ec911d5ce105c8
MD5 73277926f20cdda88cffde895e976ceb
BLAKE2b-256 3a6da5337420bdcc9dc52a2094a6aecedf2e367b404b9d391f38bd7a233de66d

See more details on using hashes here.

Provenance

The following attestation bundles were made for byok_vault-0.1.0-py3-none-any.whl:

Publisher: release.yml on felixchaos/byok-vault

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page