Metadata-Version: 2.4
Name: envelope-rotator
Version: 0.1.2
Summary: Crash-safe envelope encryption key rotation with pluggable storage backends
Project-URL: Homepage, https://github.com/Sjokle/envelope-rotator
Project-URL: Issues, https://github.com/Sjokle/envelope-rotator/issues
Author: Mehmet KOÇ
License: MIT License
        
        Copyright (c) 2026 Mehmet KOÇ
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: 3des,aes,aes-gcm,crash-safe,cryptography,dek,des,encryption,envelope-encryption,kek,key-rotation
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security :: Cryptography
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pycryptodome>=3.20
Provides-Extra: dev
Requires-Dist: fakeredis>=2.20; extra == 'dev'
Requires-Dist: mongomock>=4.1; extra == 'dev'
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: mongo
Requires-Dist: pymongo>=4.0; extra == 'mongo'
Provides-Extra: postgres
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
Provides-Extra: redis
Requires-Dist: redis>=4.0; extra == 'redis'
Description-Content-Type: text/markdown

**English** · [Türkçe](README.tr.md)

# envelope-rotator

Crash-safe key rotation for envelope encryption.

Envelope encryption splits a secret in two: a **master key** that protects your data, and a
**key-encryption key** (DEK) that wraps it. Rotating means re-wrapping the master under a fresh
DEK — the master itself never changes, so nothing it protects has to be re-encrypted.

The hard part is that a rotation touches two independent stores, and nothing will commit both
together. Get the order wrong and one interrupted rotation costs you the master permanently: the
wrapped copy is published while the only key that opens it existed solely in the memory of a
process that just died. There is no recovery from that. And because wrapping is usually not
authenticated, you will not notice until every login starts failing at once.

This package makes every interruption survivable.

```bash
pip install envelope-rotator             # core, no storage dependencies
pip install envelope-rotator[mongo]      # MongoDekStore
pip install envelope-rotator[postgres]   # PostgresDekStore + PostgresMasterStore
pip install envelope-rotator[redis]      # RedisLock
```

## Quickstart

```python
from envelope_rotator import EnvelopeKey
from envelope_rotator.stores import FileMasterStore
from envelope_rotator.stores.mongo import MongoDekStore
from envelope_rotator.locks.redis import RedisLock

key = EnvelopeKey(
    dek_store=MongoDekStore(db["deks"]),
    master_store=FileMasterStore("/var/lib/app/master.key"),
    lock=RedisLock(redis_client),
    retention=10,
)

key.initialize(os.urandom(32))   # once, on a brand-new deployment

key.master()   # the unwrapped master — cached and verified, safe on a hot path
key.rotate()   # re-wrap under a fresh DEK; safe to interrupt anywhere
key.repair()   # diagnostic: which DEK is actually live right now
```

`rotate()` returns `None` when another process holds the lock. Losing that race is a normal outcome,
not an error, so you can call it from a scheduler running in every worker without coordinating
anything yourself.

## What makes it crash-safe

Four things, and they only work together:

1. **The DEK is written before the master that needs it.** A crash in between leaves a key nobody
   points at — harmless. The other order would leave a master nobody can open.
2. **The master is written atomically.** A reader sees the whole old value or the whole new one.
3. **Every unwrapped master is checked against a fingerprint** (an HMAC-based key check value, KCV).
   Because the master never changes, its KCV is stable, so a mismatched pair is caught instead of
   silently producing garbage.
4. **On a mismatch the stored DEKs are scanned** for the one that does fit. This is what lets an
   interrupted rotation heal itself with no operator involvement.

| Process dies… | Result |
|---|---|
| before anything is written | no effect |
| after the DEK is stored | master still wrapped by the old DEK; the scan finds it |
| during the master write | atomic, so it is the whole old or whole new value; both resolve |
| after the master write | only status bookkeeping is stale; decryption unaffected |

The tests do not merely assert this: they kill a rotation at every storage operation, in both
phases, and require a client opened from scratch to still return the correct master.

## Bring your own storage

The core depends on four structural types and nothing else — no database driver, no config loader.
Implement the methods and your class fits; there is no base class to inherit and nothing to
register.

```python
class DekStore(Protocol):
    def latest(self) -> Dek | None
    def all(self) -> Iterable[Dek]            # newest first
    def insert(self, dek: Dek) -> None        # must be durable on return
    def deactivate(self, dek_id) -> None
    def set_kcv(self, dek_id, kcv: bytes) -> None
    def purge(self, keep: int) -> int

class MasterStore(Protocol):
    def load(self) -> bytes | None
    def store(self, blob: bytes) -> None      # must be atomic

class Cipher(Protocol):
    key_size: int
    def wrap(self, key: bytes, data: bytes) -> bytes
    def unwrap(self, key: bytes, data: bytes) -> bytes

class Lock(Protocol):
    def acquire(self) -> bool                 # must not block
    def release(self) -> None
```

On Postgres you do not have to write one — it ships. No driver is imported; the adapters borrow a
connection you already opened, so `psycopg2`, `psycopg` 3 and `pg8000` all work:

```python
from envelope_rotator.stores.postgres import PostgresDekStore, PostgresMasterStore

conn.autocommit = True          # see the note below

key = EnvelopeKey(
    dek_store=PostgresDekStore(conn),
    master_store=PostgresMasterStore(conn),
)

# mapping onto a schema you already have — no migration:
PostgresDekStore(conn, "my_keys", id_column="no", key_column="value",
                 active_value="A", retired_value="P", encoding="hex")
```

```sql
CREATE TABLE deks (
    dek_id BIGINT PRIMARY KEY, dek BYTEA NOT NULL, kcv BYTEA,
    status TEXT NOT NULL, created_at BIGINT, rotated_at BIGINT);
CREATE TABLE master_key (id INT PRIMARY KEY, blob BYTEA NOT NULL);
```

> **Give them a dedicated connection.** Every write commits, because the protocol requires an
> inserted DEK to be durable before `insert` returns; sharing the connection with your application
> means those commits land on your in-flight transaction too. Prefer `autocommit` as well: without
> it the driver opens a transaction on the first `SELECT` and the read path has nothing to close it,
> leaving the connection *idle in transaction* and blocking vacuum.
>
> Scope today is PostgreSQL. All the SQL emitted is portable (including `purge`, which uses two
> statements rather than a self-referencing subquery), so MySQL support would largely be a
> placeholder question — but it is **untested, so it is not claimed**.

Writing your own backend runs to about thirty lines:

```python
class PostgresDekStore:
    def __init__(self, conn):
        self.conn = conn

    def latest(self):
        row = self.conn.execute(
            "SELECT id, key, kcv, status FROM deks ORDER BY id DESC LIMIT 1"
        ).fetchone()
        return Dek(*row) if row else None

    def insert(self, dek):
        self.conn.execute(
            "INSERT INTO deks (id, key, kcv, status) VALUES (%s, %s, %s, %s)",
            (dek.id, dek.key, dek.kcv, dek.status),
        )
        self.conn.commit()          # durable before returning
    ...
```

On the `MasterStore` side the only hard requirement is that `store` be atomic. On a filesystem that
means write-to-temp plus `os.replace`; `envelope_rotator.stores.file.atomic_write_text` does it for
you. In a database, a single-row update is already atomic.

### Verify the adapter you wrote

Six methods, one query each — it looks easy. The danger is that a wrong one fails **silently**: a
`purge` that deletes too much costs nothing today and raises `NoUsableDek` months later, halfway
through a restore, which is the worst possible moment to find out.

```python
from envelope_rotator.testing import check_dek_store, check_master_store

def test_my_postgres_store():
    check_dek_store(PostgresDekStore(scratch_conn))
    check_master_store(PostgresMasterStore(scratch_conn))
```

The checks first run the real `EnvelopeKey` over your adapter, then layer targeted tests on top of
the resulting state. Every violation that can cost you the key raises `StoreContractError` and says
what would break in production:

```
StoreContractError: purge() deleted DEK 11, which was ACTIVE.

  Before: [15, 14, 13, 12, 11, 10, 9, 8, 7, 6]
  After:  [15, 14]

  Consequence: an interrupted rotation leaves an old record active while it is
  still the only key wrapping the stored master. Deleting it there destroys the
  master permanently.

  Fix: exclude active records from the DELETE unconditionally, not just when
  they fall inside the newest `keep`.
```

Findings that are wrong but not dangerous — a `purge` whose reported count is off, say — come back
as a list of advisory strings rather than an error, so an existing adapter can be adopted without a
clean sweep first.

> **These functions write and delete records.** Point them at a scratch table or collection. They
> refuse to run against a non-empty store; a live DEK store is never empty by definition, because
> `initialize` leaves at least one record behind.
>
> **Atomicity is not covered** on the `MasterStore` side: an in-process check cannot observe a torn
> write. You have to verify that one by reading the implementation.

### Adapters in the box

| | |
|---|---|
| `stores.FileMasterStore` | wrapped master alone in its own file |
| `stores.DotenvMasterStore` | one key inside an existing `.env`, other lines untouched |
| `stores.mongo.MongoDekStore` | MongoDB; every field name and status value configurable |
| `stores.postgres.PostgresDekStore` | PostgreSQL; column names, status values and binary/hex storage configurable |
| `stores.postgres.PostgresMasterStore` | wrapped master in a single-row table |
| `stores.memory.*` | in-memory, for tests |
| `locks.redis.RedisLock` | across processes and hosts |
| `locks.file.FileLock` | single host, standard library only |
| `locks.NullLock` | no locking; only where one process can ever rotate |
| `testing.check_dek_store` | validates an adapter you wrote against the protocol |

### One warning: the two stores must share a scope

`DekStore` and `MasterStore` must either both be shared or both be node-local. Mix them — a shared
`MongoDekStore` against a per-host `FileMasterStore`, say — and the blob on whichever host is not
rotating freezes in place. The KCV scan rescues it for a while, then that old DEK gets purged and
the host dies. Because it keeps working for `retention - 1` rotations without a symptom, a test
environment will not catch it.

If you run more than one host, put the master in shared storage too.

## Choosing a retention

`retention` is what makes rotation worth doing: old DEKs are eventually deleted, so a leaked copy of
your master store stops being useful. It is also your entire recovery window.

```
recovery window = (retention - 1) × rotation interval
```

Minus one, because the newest retained DEK wraps the *current* master; only the remaining
`retention - 1` help you go back.

Ten generations rotated every ten minutes is a 90-minute window — not enough to restore a day-old
backup. The same ten generations rotated daily is nine days. Pick the interval first, from how far
back you might need to go, then set retention.

Rotating often while never deleting old DEKs loses on both counts: you pay for every rotation and
get nothing for it, because an attacker holding an old copy of the master finds the key that opens
it sitting right there in your database.

## Ciphers

`AESGCM` (AES-256-GCM) is the default and what you want for anything new. It is authenticated, so a
wrong key fails loudly rather than returning plausible garbage.

`TripleDES` exists only for interoperability with systems already using it — NIST disallowed 3DES in
SP 800-131A as of 2023. Pass `TripleDES(padded=False)` if you need to read data written by code that
encrypted a block-aligned payload without padding, which is what most hand-rolled 3DES wrapping
produces.

## Adopting it on a running system

Records written before this package existed carry no KCV. On the first resolution the newest DEK is
trusted, the result pinned as the fingerprint, and everything afterwards is verified.

That first read is the one moment there is nothing to compare against, so **confirm the system is
healthy before it**: if the state is already corrupt, trust-on-first-use pins the corruption as-is.
`repair()` reports which DEK resolved and whether it was the newest. A system whose users are
logging in successfully is, by definition, in the right state.

## License

MIT
