Skip to main content

Encryption & secrets

Several settings carry sensitive values β€” pool passwords, API connector auth tokens, the OIDC client secret. The framework offers a simple primitive to keep them safe: any secret field in the Settings UI has a πŸ”’ toggle that switches the input between plain text and encrypted. Encrypted values are stored as opaque blobs that can only be decrypted by the running process; the master key that does the decryption sits in the environment of the host, never on disk.

This page covers the toggle in the UI, the master key, how to rotate keys and the convention for what to encrypt vs what to keep in the environment.


At a glance​

ENVIRONMENT
Master key (32-byte AES-256).
JWT signing key. License key. AI API key.
Never on disk.
ON DISK
Secret fields stored as opaque ciphertext blobs.
Safe to commit to git.
AT RUNTIME
Decrypted lazily at first use.
Cached for the process lifetime.

The master key never lands on disk; the encrypted blob never lands in the environment. The two only meet at decryption time, inside the running process.


The πŸ”’ toggle in the Settings UI​

Any field flagged as a secret β€” Pool Password, Connector Auth token, OIDC Client secret, Slack Webhook URL, etc. β€” shows a πŸ”’ icon next to the input. Click it to switch the field between plain and encrypted mode.

Pool editor β€” crm
URL
postgresql+asyncpg://crm@db/crm
User
crm_app
Password
πŸ”’ encrypted Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Reveal
ModeWhat happens on save
PlainThe literal value is stored. Use only for non-secret defaults (e.g. an empty password on a local SQLite pool).
πŸ”’ EncryptedThe framework encrypts the value with the current master key before saving. The field displays encrypted + a masked dot row. Editing the field overwrites the previous value (no reveal happens automatically).

A field already in encrypted mode shows a Reveal button (visible only to operators carrying settings:reveal-secrets). Clicking it asks for the master-key fingerprint as confirmation, then displays the decrypted plaintext for 10 seconds. The reveal action is audit-logged.

Some fields β€” the OIDC Client secret, the Slack Webhook URL β€” have the πŸ”’ toggle locked on. Plain mode isn't offered because the value is unambiguously sensitive.


The master key​

The master key is a 32-byte AES-256-GCM key that the framework loads from the environment at startup. The Settings UI's Framework β†’ Encryption section configures where the key comes from, not what its value is:

FieldEffect
Master keyName of the environment variable holding the key. Default ${LIBERTY_MASTER_KEY}.
Legacy keysList of older keys, used for decryption only during a rotation. New encryptions always use Master key. Click + Add legacy key to add an entry β€” each entry is an env var name.

Generate a fresh key with the liberty-crypto CLI:

.venv/bin/liberty-crypto genkey
# 7c4f1c2d8e3a6b9f0c1d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c

Export it under the env var name configured above, restart the framework, and every new save through the πŸ”’ toggle uses the new key.

A master key fingerprint (SHA-256 of the key) is surfaced on the Settings β†’ Framework β†’ Encryption section β€” useful to confirm two replicas share the same key without exposing the key itself.


Rotating the master key​

When the master key needs to change β€” annual policy, suspected leak, change of operator β€” the rotation is online:

PhaseAction
1. Generate a new keyliberty-crypto genkey. Export it under a fresh env var name (e.g. LIBERTY_MASTER_KEY_NEW).
2. Add the old key as legacyIn Settings β†’ Framework β†’ Encryption, set Master key to the new env var name and add the old env var to Legacy keys. Save β€” the framework now decrypts using either key (tries master first, then each legacy in order).
3. RestartRequired because the master key itself is read at startup, not on save-reload.
4. Re-encrypt every secretRun liberty-crypto rewrap β€” see below β€” to re-encrypt every stored blob with the new master. Idempotent; safe to re-run.
5. Drop the legacy entryOnce Rewrap reports zero remaining blobs encrypted with the old key, remove the legacy entry from the Settings form and unset the old env var. Restart.

The rewrap command:

.venv/bin/liberty-crypto rewrap
# 4 encrypted fields re-wrapped with the current master key
# 0 still using legacy keys

The command crawls every storage location, decrypts each blob with whichever key works, and re-encrypts with the current master. Idempotent β€” re-running produces a different ciphertext (new random nonce) but the plaintext is unchanged.


What to encrypt vs. what to keep in the environment​

The split is deliberate and worth following:

SecretWhere it livesWhy
Pool passwordπŸ”’ Encrypted in the pool definition.Lives next to the pool β€” one storage change updates both.
API connector auth tokenπŸ”’ Encrypted on the connector.Same reason.
OIDC client secretπŸ”’ Encrypted on the OIDC sub-form.Always encrypted (toggle locked on).
Slack webhookπŸ”’ Encrypted on the Notifications sub-form.Same.
Per-app API key for an HTTP connectorπŸ”’ Encrypted on the connector.Same as OIDC.
Master keyEnvironment only.The whole scheme depends on this never landing on disk.
JWT signing keyEnvironment only.Rotated independently of the master key.
License keyEnvironment only.RS256-signed JWT, public-key-verifiable; not sensitive to disclose, but kept in env to be replaceable without a save.
AI provider API keyEnvironment only.Convention in the Anthropic ecosystem.

A safe rule of thumb: anything that has a "scope" tied to a single connector / settings entry goes through the πŸ”’ toggle; anything that is global to the framework goes in the environment.


Permissions​

CodeEffect
settings:frameworkView the Framework β†’ Encryption section.
settings:reveal-secretsSee the Reveal button next to encrypted fields. Audit-logged.

The Master key fingerprint read-out is visible to anyone with settings:read β€” it's not sensitive on its own.


Failure modes​

SymptomCauseRecovery
connector loaded, but password rejected by databaseThe encrypted blob was wrapped with a key that's no longer in Legacy keys.Add the old key back as legacy, restart, run liberty-crypto rewrap, then drop the old key.
crypto: master key not set at startupThe env var configured on Master key is unset and at least one encrypted blob exists.Export the key under the right name; or temporarily flip the field back to plain to rescue the install.
crypto: authentication tag mismatchA blob was edited by hand, or the wrong key is loaded.Re-encrypt the value from the plaintext source; never edit ciphertext by hand.
Reveal button greyed outThe caller lacks settings:reveal-secrets.Grant the permission (or refuse β€” auditors often shouldn't reveal).

Tips & best practices​

  • Generate the master key with liberty-crypto genkey. Hand-crafted keys are a footgun β€” 32 random bytes from the framework's PRNG is the simplest correct path.
  • Use the same env-var name across replicas. Settings β†’ Framework β†’ Encryption stores the name, not the value β€” the same form works on every replica as long as the env var resolves to the same key.
  • Run liberty-crypto rewrap after every rotation. Otherwise old blobs sit on legacy keys and the rotation isn't really finished.
  • Don't disable the πŸ”’ toggle on a value that's already encrypted. Switching back to plain reveals the value on disk; the operator has to confirm and the action is audit-logged.
  • Keep legacy keys only as long as needed. Past one rotation cycle, every still-relevant blob has been rewrapped β€” the legacy entry can be dropped.

Under the hood​

Encrypted values are stored as opaque ENC:-prefixed blobs inside the per-section TOML files. Operators do not edit these blobs by hand; the πŸ”’ toggle is the only safe path. The master key fingerprint is also stored on disk so the framework can detect a mismatched key on the wrong host without exposing the key itself.

The liberty-crypto CLI is the only scripted path for advanced operations (rewrap, fingerprint inspection, hand-encryption for a script-driven setup).


What's next​