ADR-009: Key Rotation Strategy¶
Status: Accepted Date: 2026-03-11 Deciders: adk-secure-sessions maintainers
Context¶
Story 3.3 party-mode consensus (2026-03-07) asked Story 4.4 to evaluate whether extracting an EncryptionCoordinator class is needed for key rotation. The coordinator was deferred from the original architecture design as a potential abstraction over multi-backend dispatch, key provider selection, and migration strategy.
Background: Two Distinct Rotation Scenarios¶
Key rotation in production presents two fundamentally different use cases:
Path A — Cross-backend lazy rotation (e.g., Fernet to AES-GCM): Already works today via additional_backends. Old sessions encrypted with backend_id=0x01 are read transparently while new sessions are written with backend_id=0x02. No migration utility is required. Old data accumulates indefinitely with the old backend.
Path B — Same-backend passphrase rotation (e.g., two FernetBackend instances with different passphrases): Cannot use additional_backends because both backends share backend_id=0x01. The duplicate backend ID check in EncryptedSessionService.__init__ raises ConfigurationError. Requires a migration function that reads with the old key and writes with the new key.
Concurrency Constraint (NFR27)¶
The sessions table has an update_time column maintained by ADK's DatabaseSessionService (via SQLAlchemy onupdate=func.now()). This column is a natural optimistic concurrency guard: if a session is modified between the rotation function's read and write, the UPDATE WHERE update_time = <read_value> will affect zero rows, signalling a concurrent write. The record is skipped rather than overwritten with stale re-encrypted data.
A version column was reserved in Story 1.2 but was never added to the Epic 7 wrapper models. Adding it would require overriding ADK's CRUD write methods (create_session, append_event) to increment the counter — a violation of the ADK-is-upstream convention (ADR-004). The update_time column is functionally equivalent and requires no ADK method overrides.
Coordinator Evaluation¶
Multi-backend dispatch is already fully implemented via EncryptedJSON's decrypt_dispatch dictionary (mapping backend_id → sync_decrypt callable), populated at service init time from additional_backends. Key rotation adds "re-encrypt all records" — a migration task, not a dispatch task. A coordinator class would conflate dispatch (a read-time concern) with migration (a one-time operational concern), creating an unnecessary coupling.
Decision¶
No EncryptionCoordinator Class¶
A standalone rotate_encryption_keys() async function in a dedicated rotation.py module is the right abstraction. Rationale:
- Single responsibility — the function has exactly one job: re-encrypt all records from one backend key to another. This is a batch migration, not a runtime dispatch concern.
- Minimal surface — a class would add a public symbol with no protocol boundary, giving callers nothing they need beyond what the function provides.
- No coordinator protocol needed — the
EncryptionBackendprotocol already captures the backend contract. Adding a coordinator protocol for a single operation is premature generalization. - Dispatch already solved —
EncryptedJSON.decrypt_dispatchhandles runtime cross-backend dispatch. Key rotation bypasses the TypeDecorator entirely and operates on raw TEXT column values.
Two-Path Rotation Strategy¶
Path A: Lazy Cross-Backend Migration (Zero Code Changes)¶
Configure EncryptedSessionService with additional_backends:
old_fernet = FernetBackend("old-passphrase")
new_aes_gcm = AesGcmBackend(key=AESGCM.generate_key(bit_length=256))
service = EncryptedSessionService(
db_url="sqlite+aiosqlite:///sessions.db",
backend=new_aes_gcm,
additional_backends=[old_fernet],
)
- New writes use
new_aes_gcm(backend_id=0x02) - Old sessions (backend_id=0x01) are read transparently via dispatch
- No data migration required; old ciphertext accumulates
Trade-offs: No forced migration of old data; mixed-backend storage persists indefinitely until records are overwritten by normal application activity.
Path B: Batch Same-Backend Rotation¶
Call rotate_encryption_keys(db_url, old_backend, new_backend):
old_fernet = FernetBackend("old-passphrase")
new_fernet = FernetBackend("new-passphrase")
result = await rotate_encryption_keys(
db_url="sqlite+aiosqlite:///sessions.db",
old_backend=old_fernet,
new_backend=new_fernet,
)
# result.rotated: count of re-encrypted records
# result.skipped: count skipped due to concurrent writes
The function: 1. Opens an async engine from db_url 2. For each of the 4 encrypted tables (sessions, app_states, user_states, events): reads all rows, identifies records matching old_backend.backend_id by parsing the envelope header from the base64-decoded TEXT column 3. For each matching row: decrypts with old_backend.sync_decrypt, re-encrypts with new_backend.sync_encrypt, builds a new envelope, and writes back with an optimistic concurrency check on update_time 4. Crypto calls are wrapped in asyncio.to_thread() (CPU-bound rule, ADR-002) 5. Returns RotationResult(rotated=N, skipped=M)
Trade-offs: Complete migration in a single operation, but may require a maintenance window for large databases. Concurrent writes during rotation are handled gracefully (skipped records can be rotated in a follow-up call).
update_time as Optimistic Concurrency Guard¶
The rotation function reads a row's update_time, re-encrypts the state, and executes:
UPDATE sessions
SET state = :new_encrypted_state
WHERE app_name = :app_name
AND user_id = :user_id
AND id = :id
AND update_time = :read_update_time
If rows_affected == 0, a concurrent write occurred between read and write. The record is added to the skipped count. This is intentional: skipping preserves the most recent write and avoids data loss. Operators can run rotate_encryption_keys again to pick up skipped records.
Key Safety (NFR6)¶
No key material, passphrases, or derived key bytes may appear in any error message or log output from the rotation function. Record identifiers (row primary keys) are safe metadata and may appear in logs.
Alternatives Considered¶
EncryptionCoordinator Class¶
Rejected. A class would create a public symbol with no protocol boundary, conflating runtime dispatch (already solved by EncryptedJSON) with batch migration. The single-responsibility rotate_encryption_keys() function is simpler, more discoverable, and covers all required use cases.
version Column for Optimistic Concurrency¶
Rejected. Adding a version column to the encrypted models would require overriding ADK's CRUD write methods to increment the counter, violating the ADK-is-upstream principle (ADR-004). The update_time column provides equivalent protection with no override required.
Lazy-Only Strategy (No Batch Migration)¶
Rejected. Same-backend passphrase rotation is a legitimate compliance requirement (FR48). Lazy rotation via additional_backends cannot address this scenario because duplicate backend_id values are rejected at service init.
Envelope Header Extension for Old/New Key Tracking¶
Rejected. Adding key identity to the envelope header would break the binary wire protocol (ADR-000) and require all consumers to be updated simultaneously. The rotation function's approach — detect by backend_id byte in existing envelope — is backward-compatible and requires no protocol changes.
Consequences¶
Positive¶
rotate_encryption_keys()provides a complete, production-safe batch migration path for same-backend passphrase rotation- Path A (lazy cross-backend) remains zero-change for operators migrating between backend types
- Optimistic concurrency prevents data loss during concurrent writes
- No key material in error messages (NFR6 compliance)
- No ADK method overrides required
Negative¶
- Batch rotation requires an async database connection at call time; the caller owns the database URL, not just the service instance
update_timeis not incremented by the rotation function itself (it reads and conditionally updates in a singleUPDATEstatement); SQLAlchemyonupdatetriggers only on ORM-level writes, not raw SQL- Skipped records require a follow-up rotation call; there is no automatic retry
Neutral¶
- The rotation function operates outside the
EncryptedJSONTypeDecorator — it reads raw TEXT column values and manually parses envelopes. This is intentional: the rotation function must control which key is used for decryption, bypassing the TypeDecorator's configured dispatch. _build_envelopeand_parse_envelopefromserialization.pyare reused directly inrotation.py
References¶
- ADR-000: Envelope wire protocol
- ADR-002: Async-first design,
asyncio.to_thread()rule - ADR-004: ADK-is-upstream principle
- ADR-007:
update_timecolumn source and TypeDecorator architecture - ADR-008: Per-key salt in FernetBackend (relevant to same-backend rotation)
src/adk_secure_sessions/services/encrypted_session.py:additional_backendsdispatch implementation- Story 3.3 party-mode consensus (2026-03-07): coordinator evaluation trigger