Key Management
Encryption key derivation, rotation, environment secret responsibilities, and security best practices for Open-ACE.
Table of Contents
- Overview
- Environment Secret Responsibility Matrix
- Key Derivation
- Key Sharing Impact
- Key Rotation
- The OPENACE_ENCRYPTION_KEYS Data-Key Registry
- Security Best Practices
- Database Schema
- Troubleshooting
- Related Documentation
Overview
Open-ACE uses Fernet symmetric encryption to protect sensitive data at rest. The same key directly encrypts all 8 stores (authoritative list in Key Sharing Impact below):
- API keys for remote workspaces (
api_key_storetable) - SMTP passwords (
smtp_settingstable) - Model Gateway API keys (
model_gateway_configtable) - SSO provider credentials (
sso_providerstable) - DingTalk integration (
dingtalk_settingstable) - Feishu integration (
feishu_settingstable) - Webhook configs (
webhook_settingstable) - Notification preferences (
notification_preferencestable)
Proxy tokens use HMAC-SHA256 signatures (not Fernet) for authentication.
Environment Secret Responsibility Matrix
The 13 variables defined in .env.example split into secrets, gates, and plain configuration. Full details (defaults, sources) are in ENV_REFERENCE.md.
| Variable | Responsibility | Production | Rotation impact |
|---|---|---|---|
SECRET_KEY | Flask session signing | Required, >= 32 chars | All logged-in sessions invalidated (everyone logged out); no data loss |
OPENACE_ENCRYPTION_KEY | Encrypts all 8 secret stores; signs proxy tokens | Required, >= 32 chars | Old-key ciphertext becomes permanently undecryptable unless re-encrypted first (see Key Rotation); active proxy tokens invalidated |
UPLOAD_AUTH_KEY | Authenticates workspace file-upload endpoints | Recommended | Upload clients rejected until updated; empty value disables the endpoint |
DB_PASSWORD | PostgreSQL authentication | Required, strong (>= 9 chars) | Change in .env AND in PostgreSQL, then restart; mismatch = connection failures |
OPENACE_SECURITY_MODE | Not a secret: gates which secrets are mandatory | production | Switching modes changes startup strictness, not stored data |
SSO_ALLOWED_REDIRECT_DOMAINS | Not a secret: SSO redirect allowlist | Required for non-localhost SSO | Changing the list changes where SSO logins may land |
DB_USER, DB_NAME | Database identity | ace / ace | Not routine rotation; requires re-provisioning |
PORT, SERVER_IP, WORKSPACE_PORT_RANGE_START/END | Network addressing | — | Changes URLs/bookmarks; no secret impact |
WORKSPACE_ISOLATION_BACKEND | Workspace isolation backend switch (Issue #3446; replaces WORKSPACE_MULTI_USER_MODE) | — | Changes security posture, not secrets |
Key Derivation
The encryption key is derived from the OPENACE_ENCRYPTION_KEY environment variable:
OPENACE_ENCRYPTION_KEY (env var, >= 32 chars)
│
│ SHA-256 hash
▼
32-byte key
│
│ base64.urlsafe_b64encode
▼
Fernet key (44 chars)
│
├──────────────────────────────┐
▼ ▼
ALL 8 encrypted stores Proxy Token
(api_key_store / smtp_settings / signing (HMAC-SHA256,
model_gateway_config / not Fernet)
sso_providers / dingtalk_settings /
feishu_settings / webhook_settings /
notification_preferences —
full list in "Key Sharing Impact")
Key derivation code:
import hashlib
import base64
key_env = os.environ.get("OPENACE_ENCRYPTION_KEY")
derived_key = hashlib.sha256(key_env.encode()).digest()
fernet_key = base64.urlsafe_b64encode(derived_key)
Key Sharing Impact
The same key (OPENACE_ENCRYPTION_KEY, SHA-256-derived Fernet key) directly encrypts every store below:
- API Key encryption -
api_key_store.encrypted_key - SMTP password encryption -
smtp_settings.encrypted_password - Model Gateway encryption -
model_gateway_config.encrypted_api_key - SSO provider configs -
sso_providers(third-party credentials embedded in JSON) - DingTalk integration -
dingtalk_settings - Feishu integration -
feishu_settings - Webhook configs -
webhook_settings - Notification preferences -
notification_preferences
The same key also signs Proxy Tokens (HMAC-SHA256, remote agent authentication).
Implications:
- Rotation must re-encrypt ALL of the stores above in one pass — rotating only a subset leaves the remaining ciphertexts undecryptable after the key switch (this is exactly why
scripts/rotate_sso_encryption.pyrotates every store in a single transaction) - Active proxy tokens signed with the old key will fail validation after rotation
- Key compromise affects all of the security domains above
Key Rotation
Rotation Capabilities and Boundaries
- Atomic full-store rotation:
scripts/rotate_sso_encryption.pyre-encrypts EVERY store bound to this key in a single transaction (full rollback on any failure);--verifyprovides a no-write pre-flight - Single-key Fernet: No MultiFernet multi-key decryption (see "MultiFernet Support (Future enhancement)" below)
- Restart on switch: the service must be restarted after the environment variable switches to the new key
Rotation Method
Atomic full-store rotation (rotate_sso_encryption.py)
Prerequisites:
- Database backup capability
- Planned maintenance window
- Root access to environment variables
Steps:
-
Backup the database
# PostgreSQLpg_dump openace > openace_backup_$(date +%Y%m%d).sql# SQLitecp app.db app_backup_$(date +%Y%m%d).db -
Generate a new key (do NOT enable it yet)
NEW_KEY=$(openssl rand -hex 32) -
Stop every reader/writer that uses the old key (app, scheduler/workers; the database itself stays up). This is a PRECONDITION of the script's safety, not an option: the script takes no table locks; each UPDATE is only optimistic on the value it scanned (a concurrent MODIFICATION of a scanned row → 0/2 affected rows → full rollback), but rows INSERTED after a table's scan are invisible inside the transaction, and the post-check can only REPORT committed mixed-key data, never roll it back — old-key ciphertext WILL remain if writers stay up.
-
Keep the environment on the OLD key and run the pre-flight dry run
python scripts/rotate_sso_encryption.py --new-key "$NEW_KEY" --verifyThe pre-flight round-trips a probe with the new key and verifies every store decrypts under the old key; nothing is written on failure. The environment must stay on the OLD key for the whole rotation — switching early makes the old ciphertexts undecryptable and fails the pre-flight.
-
Run the rotation (single transaction, per-UPDATE row-count validation; a concurrent modification rolls the whole rotation back)
python scripts/rotate_sso_encryption.py --new-key "$NEW_KEY"Every store the key directly protects is re-encrypted (registry-format ciphertexts prefixed
v1k<id>:are bound to theOPENACE_ENCRYPTION_KEYSdata keys and are skipped automatically), and the script re-verifies all stores with the new key afterwards. -
Switch the environment to the new key and restart ALL services
# Docker Compose: edit .env# Kubernetes: update Secret# Systemd: edit /etc/open-ace/environmentdocker compose restart # or: sudo systemctl restart open-ace -
Verify functionality: API keys, SMTP, Model Gateway, SSO login, DingTalk/Feishu/webhooks/notifications. Existing proxy tokens are invalidated (users must restart sessions).
-
Secure cleanup: archive or remove the database backup after verification.
MultiFernet Support (Future enhancement)
Requirements:
- Code modification to support
MultiFernet - Environment variable format:
KEY1;KEY2(primary;fallback) - Zero-downtime rotation capability
Implementation needed:
- Modify
_get_encryption_key()to return key list - Use
MultiFernet([key1, key2])for decryption - Use primary key for new encryption
- Gradual migration path
The OPENACE_ENCRYPTION_KEYS Data-Key Registry
Besides the single OPENACE_ENCRYPTION_KEY, Open-ACE supports a versioned data-key registry read from the OPENACE_ENCRYPTION_KEYS environment variable (app/utils/encryption_key_registry.py):
{
"keys": [
{"id": 1, "value": "<old-key-material>", "status": "deprecated"},
{"id": 2, "value": "<new-key-material>", "status": "active"}
],
"primary_key_id": 2
}
Rules enforced at load time:
idmust be an integer between 1 and 255, unique; at most 5 keys total- Exactly one key may be
active, andprimary_key_idmust point at it; other keys aredeprecated(decrypt-only) orrevoked(emergency decrypt-only) - Each key value is SHA-256-derived exactly like
OPENACE_ENCRYPTION_KEY - When
OPENACE_ENCRYPTION_KEYSis set it takes precedence over the single-key format; the legacy single key is registered askey_id=0 - The registry hot-reloads: environment changes are picked up lazily, checked roughly every 5 seconds
- A config hash/version is computed over the key set so multiple replicas can detect divergence
Ciphertexts produced through the registry carry a key-id prefix (v1k<key_id>:<fernet_ciphertext>), which is what allows old keys to keep decrypting old data while new writes use the primary key. scripts/rotate_sso_encryption.py skips these registry-bound ciphertexts automatically.
Syncing key metadata into the database
scripts/migrate_encryption_keys_to_db.py copies key metadata (fingerprints, statuses — never plaintext values) from the environment into the database for the management UI:
# Preview (dry-run)
python scripts/migrate_encryption_keys_to_db.py --dry-run
# Execute
python scripts/migrate_encryption_keys_to_db.py --execute
Management API (platform admin only)
Endpoints implemented in app/routes/encryption_keys.py:
| Endpoint | Purpose |
|---|---|
GET /api/encryption-keys | List all key metadata |
POST /api/encryption-keys/validate | Validate a key's format, return its fingerprint |
POST /api/encryption-keys/rotate | Rotate the data key (body: {"confirmation": "ROTATE"}, optional expected_version optimistic lock) |
POST /api/encryption-keys/generate-env-config | Generate the OPENACE_ENCRYPTION_KEYS value for external systems |
GET /api/encryption-keys/audit-log | Query key-operation audit log (rotate, re-encrypt filterable) |
GET /api/encryption-keys/sync-status | Compare config versions across replicas (uses OPENACE_REPLICA_ENDPOINTS) |
POST /api/encryption-keys/re-encrypt/pre-check | Scan stored ciphertexts (SSO providers, API keys): counts v1k-prefixed vs legacy formats, tests decryption |
POST /api/encryption-keys/re-encrypt | Re-encrypt legacy ciphertexts through the registry (body: {"confirmation": "RE-ENCRYPT"}) |
Security Best Practices
Key Generation
# Generate a strong random key (256 bits = 32 bytes = 64 hex chars)
openssl rand -hex 32
Key Storage
- Never commit to source control
- Use environment variables or secrets management:
- Docker Compose:
.envfile (add to.gitignore) - Kubernetes: Secrets resource
- Cloud: AWS Secrets Manager, Azure Key Vault, GCP Secret Manager
- Docker Compose:
Key Rotation Schedule
- Recommended: Every 90 days
- Required: Immediately after suspected compromise
- Document: Maintain rotation log with timestamps
Key Compromise Response
- Generate the new key but do not enable it yet; back up the affected stores and pause writes to them
- Revoke all active proxy tokens (if applicable)
- Keep
OPENACE_ENCRYPTION_KEYset to the old key and rotate all encrypted credentials:scripts/rotate_sso_encryption.py --new-key <NEW_KEY>re-encrypts every store protected by the key in a single transaction (sso_providers,api_key_store,smtp_settings,model_gateway_config,dingtalk_settings,feishu_settings,webhook_settings,notification_preferences;v1k<id>:-prefixed key-registry ciphertexts are bound to theOPENACE_ENCRYPTION_KEYSdata keys instead and are skipped automatically). Run the--verifypre-flight first. The script reads the environment variable as the old key and--new-keyas the new one — switching the environment variable before rotating makes the old ciphertext undecryptable and fails the pre-flight - Only after the rotation completes (the script re-checks every store with the new key after writing) and no other store sharing this key remains, switch
OPENACE_ENCRYPTION_KEYto the new key and restart the service - Audit access logs for suspicious activity; document incident and remediation steps
Database Schema
Encryption Version Field
Tables with encrypted data include an encryption_version field:
api_key_store.encryption_version(default: 1)smtp_settings.encryption_version(default: 1)model_gateway_config.encryption_version(default: 1)
Version mapping:
| Version | Algorithm | Notes |
|---|---|---|
| 1 | Fernet (AES-128-CBC + HMAC-SHA256) | Current |
| 2+ | Reserved for future algorithms | e.g., AES-256-GCM |
Future algorithm upgrades will:
- Support reading version 1 data
- Write new data with version 2
- Provide migration scripts for gradual transition
Troubleshooting
"Invalid Fernet key" errors
- Verify
OPENACE_ENCRYPTION_KEYis set - Check key format (should be hex or base64, >= 32 chars)
- Ensure no whitespace or newlines in the value
Decryption failures after rotation
- Confirm you're using the correct key for the data's encryption version
- Check if data was encrypted with a different key
- Restore from backup if key is lost
Proxy tokens invalid after rotation
- Expected behavior: tokens signed with old key
- Users need to restart remote sessions
- No action needed if sessions are short-lived
Related Documentation
- ENV_REFERENCE.md — environment variable table with rotation impact
- UPGRADING.md — keeping keys compatible across upgrades
- DEPLOYMENT.md — production deployment guide
- REMOTE_WORKSPACE.md — feature overview
- Security Policy — security reporting and policy details