Secret management
This is the authoritative inventory, threat model, and rotation runbook for credentials persisted by k0smos. It applies per tenant database. Deployment secrets such as database/Redis DSNs and the encryption keyring remain in the process environment or an external secret manager.
Classification and inventory
Reversible credentials are needed in clear text briefly when k0smos calls an
external service. They use the authenticated enc:v2: envelope. The exact
app_settings schema is code-owned by AppSettingOwnerCatalog; key-name
substrings are never used to infer sensitivity.
| Owner | Exact app_settings fields |
Notes |
|---|---|---|
| Psapi / Wpapi / Esapi | psapi.api_key; wpapi.wp_application_password, wpapi.woo_consumer_key, wpapi.woo_consumer_secret; esapi.api_key |
Import connector credentials. |
| Google / Maps / Facebook | google.api_key, google.maps.api_key, google.oauth2.client_secret, maps.google_api_key, facebook.app_secret |
OAuth client IDs and redirect URIs are identifiers, not secrets. |
| Captcha / mail | captcha.secret_key, mail.smtp.password |
Runtime verification and SMTP authentication. |
| Payment | payment.token_secret, payment.methods.stripe.secret_key, payment.methods.stripe.webhook_secret, payment.methods.paypal.client_secret, payment.methods.revolut.api_key, payment.methods.revolut.webhook_secret |
Provider secrets and webhook authentication. |
| AI | ai.provider.profiles, ai.provider.default.api_key, ai.anonymizer.remote.auth_token; client.{openai,ollama,anthropic,openrouter,nvidia,atlascloud,kimi,sensenova}.api_key |
The complete profile value is protected when credentials are nested JSON. |
| Integration registries | integration.clients.registry, integration.agent_connectors.registry |
The complete structured registry is protected; queue messages remain credential-free. |
| Storage | storage.profiles |
The complete structured registry protects passwords, private keys, and passphrases. |
| Calendar / Invoice | calendar.integration.google.access_token, calendar.integration.google.refresh_token, invoice.access_token |
Delegated and vendor access credentials. |
| Monitoring / imports | fireflyiiiapi.token, oneuptimeapi.api_key, oneuptimeapi.default_heartbeat_secret |
Admin payloads expose only presence/masked state. |
| Login hardening | security.login.secret_key |
The admin API never returns the key or embeds it in the displayed login path. |
Reversible secrets outside app_settings:
| Storage | Field | Maintenance command |
|---|---|---|
users |
totp_secret |
secrets:user-totp |
module_opcua_servers |
password |
opcua:secrets (only when Opcua is active) |
Credentials that must remain one-way are deliberately not encrypted:
- user and Ecommerce customer password hashes;
- legacy and scoped personal API-token SHA-256 hashes;
- OAuth authorization-code, access-token, and rotating refresh-token hashes;
- payment webhook replay-token hashes and pending-change email OTP hashes.
The legacy users.password compatibility column is treated as a password-hash
column by the repository. Cart.token and Data Exchange translation tokens are
workflow identifiers rather than operator/service credentials and are not part
of the reversible-secret catalog.
Envelope and keyring contract
SecretStoreInterface is provider-neutral. The shipped implementation uses
AES-256-GCM with a random 96-bit nonce and 128-bit authentication tag. The
compact base64url envelope is:
enc:v2:<version | algorithm | key-id length | key-id |
SHA-256(context) | nonce | tag | ciphertext>
The authenticated associated data contains purpose, tenant ID, declared owner,
and exact field. Moving an envelope to another tenant, owner, or field therefore
fails authentication. The compact representation safely fits generated TOTP
secrets in the existing VARCHAR(255) column, so no schema widening is needed.
Production keys are independent from JWT_SECRET:
K0SMOS_SECRET_CURRENT_KEY_ID=2026-08
K0SMOS_SECRET_KEYS={"2026-08":"BASE64_ENCODED_32_BYTE_KEY"}
K0SMOS_SECRET_KEYS is a JSON map containing the current key and at most three
previous 32-byte keys, each encoded with standard base64. Key IDs contain only
letters, digits, dot, underscore, or dash and are at most 64 characters. Generate
key material through the platform secret manager or openssl rand -base64 32;
never paste generated values into committed files, tickets, logs, or command
history shared with other users.
enc:v1: AppSettings values remain readable during migration using the legacy
JWT_SECRET derivation, but every write produces enc:v2:. TOTP and OPC UA
plaintext rows are also readable only as a migration bridge; new writes are
always v2. Authentication, missing-key, and malformed-envelope failures are
hard errors and never degrade to ciphertext or an empty value.
Threat model and trust boundaries
| Threat | Protection / remaining boundary |
|---|---|
| Database dump or stolen database backup | Values are authenticated ciphertext; keys are stored separately. Row names, key IDs, sizes, and presence remain visible metadata. |
| Log, trace, debug panel, template, or queue leak | Maintenance returns counts/key IDs only; known UI payloads are write-only/masked; exceptions are redacted; queue contracts must not carry credentials. A new consumer still requires review. |
| Cross-tenant or cross-field copy | Tenant/owner/field context is authenticated and a copied envelope fails closed. |
| Compromised application process | Not mitigated: a process with the tenant database and keyring can decrypt credentials used by that tenant. Restrict process, debug, shell, and secret-manager access. |
| Malicious application administrator | The UI cannot read stored secrets, but an authorized administrator can replace them and trigger integrations. ACL and audit controls remain required. |
| Stolen keyring alone | It is insufficient without an encrypted database/backup, but must still be treated as a critical secret. |
| Loss of all applicable keys | Permanent data loss for every envelope using those keys. There is no recovery bypass. Restore a matching escrowed keyring or replace credentials. |
| Partial/interrupted rotation | Commands are rerunnable, use bounded transactions, skip current envelopes, and compare the scanned value during update so concurrent application writes are not overwritten. |
| Rollback | Readers must be deployed before v2 writes. A binary rollback to a release that cannot read v2 is unsafe after protection starts; roll forward or deploy a compatibility reader while retaining old keys. |
Backup and disaster recovery gate
Before the first v2 write or every key rotation:
- Back up each tenant database and verify its restore in an isolated location.
- Export the complete current/previous keyring to a separate encrypted escrow with access control, recovery ownership, and a recorded key-ID inventory.
- Prove the restored application can run
verifyfor AppSettings, TOTP, and active module-owned secret stores without revealing values. - Record backup retention. An old encrypted backup still requires its old key; do not delete that key until the backup expires or is replaced by a verified post-rotation backup.
Never store the database backup and its only keyring copy in the same security domain. Recovery operators must know that losing every matching key makes the encrypted values unrecoverable; vendor credentials then need replacement.
Inventory, protection, and verification
Run the applicable commands for every tenant. Examples use a reserved hostname:
php bin/console secrets:app-settings inventory --host=k0smos.example.com
php bin/console secrets:user-totp inventory --host=k0smos.example.com
php bin/console opcua:secrets inventory --host=k0smos.example.com
php bin/console secrets:app-settings protect --dry-run --host=k0smos.example.com
php bin/console secrets:user-totp protect --dry-run --host=k0smos.example.com
php bin/console opcua:secrets protect --dry-run --host=k0smos.example.com
php bin/console secrets:app-settings protect --batch-size=100 --host=k0smos.example.com
php bin/console secrets:user-totp protect --batch-size=100 --host=k0smos.example.com
php bin/console opcua:secrets protect --batch-size=100 --host=k0smos.example.com
php bin/console secrets:app-settings verify --host=k0smos.example.com
php bin/console secrets:user-totp verify --host=k0smos.example.com
php bin/console opcua:secrets verify --host=k0smos.example.com
protect and rotate are equivalent maintenance writes: they protect
plaintext/legacy rows and rekey previous-key envelopes. status/inventory
report counts without decrypting valid envelopes; verify authenticates every
envelope and fails while plaintext, legacy, stale, or unreadable values remain.
Opcua commands exist only for tenants where that module is active.
Dual-read, single-write rotation
-
Create the new 32-byte key and add it to
K0SMOS_SECRET_KEYSbeside the old key. Do not remove the old entry. -
Deploy readers containing both keys to every web/CLI/worker process, then set
K0SMOS_SECRET_CURRENT_KEY_IDto the new ID. Restart processes so they share one keyring view. -
Take and verify the pre-rotation database/keyring backup.
-
Run each
rotate --dry-run, thenrotate --batch-size=100, for every tenant. Rerun after interruption; already-current rows are skipped. -
Run all applicable
verifycommands. They must report zero plaintext, legacy, stale, and invalid rows. -
Run all applicable retirement checks:
php bin/console secrets:app-settings retire-check --retire-key=2026-07 --host=k0smos.example.com php bin/console secrets:user-totp retire-check --retire-key=2026-07 --host=k0smos.example.com php bin/console opcua:secrets retire-check --retire-key=2026-07 --host=k0smos.example.com -
Take and restore-test a post-rotation backup. Keep the old key while any retained backup or tenant can still contain its key ID.
-
Only after every tenant, process, active module, and retained backup is clear, remove the previous key and restart processes. Repeat
verifyafterwards.
Owner-by-owner migration is supported by writing a credential through its normal
admin service before the bulk command. After each tranche, verify is the
authoritative isolated-database proof that every catalogued non-empty row is
v2 and readable.