Skip to content

feat!: replace flat master-key vault encryption with Argon2id KEK/DEK model#343

Merged
beubax merged 1 commit into
mainfrom
feature/vault-aes-gcm-dek-kek
May 27, 2026
Merged

feat!: replace flat master-key vault encryption with Argon2id KEK/DEK model#343
beubax merged 1 commit into
mainfrom
feature/vault-aes-gcm-dek-kek

Conversation

@manojbajaj95
Copy link
Copy Markdown
Collaborator

@manojbajaj95 manojbajaj95 commented May 27, 2026

Motivation

The previous vault used Fernet (AES-128-CBC + HMAC-SHA256) with a master key loaded directly from env, file, or keyring — a flat, single-key model where the master secret encrypted every credential blob directly. This design has two critical weaknesses:

  1. No key rotation without re-encrypting everything. Because the master key is the encryption key, rotating it means decrypting and re-encrypting every stored credential.
  2. Single-point-of-failure secret. If an attacker recovers the master key, every credential in every collection is immediately readable.

Design

Envelope encryption — the same model used by AWS Secrets Manager, HashiCorp Vault, and GCP KMS:

master_secret (env → file → keyring → auto-generate)
    └─▶ Argon2id(master_secret, salt) ──▶ KEK  [never stored]
              └─▶ AES-256-GCM.decrypt(KEK, wrapped_dek) ──▶ DEK  [in memory only]
                        └─▶ AES-256-GCM(DEK, plaintext) ──▶ encrypted blob  [in KV store]

Why a DEK?
The random 256-bit DEK is what actually encrypts credential blobs. The master secret only ever touches the DEK (wrapped + stored in __vault_meta__:__dek__). Key rotation now means re-wrapping one DEK record, not re-encrypting every credential.

Why Argon2id for all master secrets?
Previously there were two code paths — passphrase (KDF) vs raw key (direct). Maintaining two paths is error-prone and confusing. Argon2id on a high-entropy random key is redundant but safe — the cost is a ~100ms KDF on startup, which is acceptable. A single path eliminates the distinction entirely.

Why store the wrapped DEK in the KV store?
The DEK record lives at __vault_meta__:__dek__ in the same KV backend as credentials. This means the vault works with any KV backend (local DiskStore, remote DynamoDB, Redis) without a sidecar file — important for serverless and distributed deployments. The wrapped DEK is protected by AES-256-GCM with the KEK; it is useless without the master secret.

Why AES-256-GCM over Fernet?
AES-256-GCM is an AEAD primitive (authenticated encryption with additional data) with a 256-bit key. It is the current standard for symmetric encryption and is directly available in cryptography.hazmat. Fernet uses AES-128-CBC + HMAC-SHA256 (two primitives, 128-bit key) and is a higher-level convenience wrapper not intended for custom key hierarchies.

Changes

  • MasterSecretResolver: unified AUTHSOME_MASTER_KEY resolution (env → file → keyring → auto-generate); passphrase and raw key both go through Argon2id — no separate code paths
  • DekManager: generates a random 256-bit DEK, wraps it with an Argon2id-derived KEK (AES-256-GCM), and stores the wrapped record in the KV store under __vault_meta__:__dek__
  • AesGcmEncryptionWrapper: BaseEncryptionWrapper subclass using AES-256-GCM per-value encryption; replaces FernetEncryptionWrapper
  • Vault: simplified — takes an already-encrypted AsyncKeyValue; close() removed (caller manages store lifecycle)
  • EncryptionConfig removed from ServerConfig and auth.models.__all__
  • Health route updated to report crypto_source / crypto_mode from Vault properties
  • Tests rewritten with fixtures and SimpleStore (in-memory); DiskStore removed from test suite

Breaking changes

Existing Fernet-encrypted vaults cannot be read after this change. Users will need to re-import credentials (authsome login again for each provider).

Test plan

  • uv run pytest — all 321 tests pass
  • uv run ruff check src/ tests/ — clean
  • uv run ty check src/ — clean
  • Manual: authsome init && authsome login github on a fresh ~/.authsome
  • Manual: restart daemon — confirm DEK survives reload (same __vault_meta__:__dek__ record, same plaintext retrieved)
  • Manual: GET /health returns "encryption_source": "aes-256-gcm" and "encryption_backend": "Argon2id"

🤖 Generated with Claude Code

…model

Replaces the flat FernetEncryptionWrapper + EncryptionConfig model with a
proper envelope encryption scheme:

- MasterSecretResolver: unified resolution order (env → file → keyring →
  auto-generate) under a single AUTHSOME_MASTER_KEY env var; no separate
  passphrase vs raw-key distinction — both go through Argon2id
- DekManager: generates a random 256-bit DEK, wraps it with an
  Argon2id-derived KEK (AES-256-GCM), and stores the wrapped record in the
  KV store under __vault_meta__:__dek__ so it works with any KV backend
- AesGcmEncryptionWrapper: drop-in BaseEncryptionWrapper using AES-256-GCM
  per-value encryption via closure; replaces FernetEncryptionWrapper
- Vault: simplified — no longer owns crypto or lifecycle; receives an
  already-encrypted AsyncKeyValue; close() removed (caller manages store)
- EncryptionConfig removed from ServerConfig and models __all__
- Health route updated to report crypto_source from Vault properties
- Tests rewritten with fixtures and SimpleStore (in-memory); no DiskStore

BREAKING CHANGE: existing Fernet-encrypted vaults cannot be read back;
migration requires re-importing credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Entire-Checkpoint: a778dfa71075
@beubax beubax merged commit 35d0c62 into main May 27, 2026
5 checks passed
@beubax beubax deleted the feature/vault-aes-gcm-dek-kek branch May 27, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants