Metadata-Version: 2.4
Name: byok-vault
Version: 0.1.0
Summary: Per-user, per-service envelope encryption (AES-256-GCM + HKDF) for BYOK API-key storage
Project-URL: Homepage, https://github.com/felixchaos/byok-vault
Project-URL: Issues, https://github.com/felixchaos/byok-vault/issues
Author: Stellatrix Labs
License-Expression: MIT
License-File: LICENSE
Keywords: aes-gcm,api-key,byok,cryptography,encryption,envelope-encryption,hkdf,secrets
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography
Description-Content-Type: text/markdown

# 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`](https://pypi.org/project/cryptography/).

## Install

```bash
pip install byok-vault
```

Requires Python 3.10+.

## Quickstart

```python
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:

```python
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](LICENSE).

## Acknowledgements

Extracted from the RPG Roleplay platform (https://github.com/felixchaos/rpg-roleplay-platform).
