ADR-0012: Encrypted channel secrets, hashed tenant API keys and recursive audit redaction
| Status | Accepted |
| Date | 2026-10-08 |
| Issue | #12 |
| Pull request | #79 |
| Related | ADR-0009, ADR-0014, ADR-0022 |
Converger holds three kinds of credentials: tenant API keys (server-to-server auth on the tenant API), channel secrets (inbound signature keys and channel token issuance) and provider credentials inside channels.config (Meta access_token, app_secret and verify_token, Infobip api_key, webhook headers.Authorization). This ADR records how each is stored, how it is looked up without being a plaintext query key, and how it is kept out of audit logs and the admin UI.
Context and problem statement
Before #79:
- Audit logs leaked provider tokens.
Converger.AuditLogs.Changes.serialize/1dropped only the top-level fieldsapi_key,secret,password_hashandpassword.channels.configis a nested map, so every channel create or update wroteaccess_token,verify_tokenand Infobipapi_keyintoaudit_logs.changesin clear text, readable by any admin viewer and kept indefinitely. - Plaintext at rest.
tenants.api_key,channels.secretandchannels.configwere plain columns. A database dump, a backup copy or read-only SQL access exposed every provider credential and every tenant API key. - Full keys in the UI. The admin tenant list displayed every tenant's API key in full.
- Secrets as lookup keys. Channels were found with
Repo.get_by(secret: ...), so the full secret was the indexed lookup value and the comparison was not constant time. - A verify-token bypass. The WhatsApp Meta
hub.verify_tokencheck comparednil == nilas a match when noverify_tokenwas configured.
Decision drivers
- A database dump alone must not reveal usable credentials.
- Values that Converger must use again (channel secrets for HMAC, provider tokens for outbound calls) need reversible encryption; values it only has to verify (tenant API keys) do not.
- Lookup by secret must keep working without storing or querying the plaintext.
- Key rotation for both the encryption key and the tenant API keys, without downtime.
- Migrations must run under
Converger.Release.migrate/0, where only the application is loaded, not started. - Audit logs must stay useful (which fields changed) without containing secrets, whatever the nesting depth.
Considered options
- Application-level encryption with
cloak_ectofor reversible secrets, SHA-256 hashes for API keys, recursive key-based redaction - the proposal in the issue. - Database-level encryption (
pgcryptofunctions or transparent disk/tablespace encryption). - External secret manager (HashiCorp Vault, AWS KMS or Secrets Manager) holding the credentials, with only references in the database.
- Hash everything, including channel secrets.
Pros and cons of the options
Option 1: Cloak + hashes + redaction
- Good: ciphertext in every row, dump and backup; the key lives only in the release environment (
CLOAK_KEY). - Good: AES-256-GCM is authenticated, so tampered ciphertext fails to decrypt instead of yielding garbage.
- Good: Ecto types make encryption transparent to the rest of the code (
Converger.Encrypted.Binary,Converger.Encrypted.Map). - Good: built-in support for retired keys during rotation.
- Good: no new infrastructure.
- Bad: losing
CLOAK_KEYloses every channel secret and config. - Bad: encrypted columns cannot be queried or indexed by value, so a separate hash column is needed for lookups.
Option 2: database-level encryption
- Good: no application changes for disk-level encryption.
- Bad: transparent disk encryption does not protect against SQL access or logical dumps, which were the threat.
- Bad:
pgcryptoputs the key in SQL statements, where it can end up in logs andpg_stat_statements.
Option 3: external secret manager
- Good: central audit and rotation, hardware-backed keys.
- Bad: a hard dependency on infrastructure many self-hosted deployments do not have.
- Bad: a network round trip on paths such as inbound verification unless cached, and the cache brings back the problem.
- Can be added later behind the vault (Cloak supports custom ciphers) without changing the schema.
Option 4: hash everything
- Good: nothing reversible is stored.
- Bad: impossible for channel secrets (needed as HMAC keys for inbound verification and outbound signing) and provider tokens (sent to Meta and Infobip).
Decision
Chosen option: "Cloak AES-GCM vault for reversible secrets, SHA-256 hashes for tenant API keys, recursive audit redaction", because it removes plaintext credentials from the database with no new infrastructure, keeps every existing integration working, and matches each secret type to the weakest storage that still lets Converger use it.
Channel secrets and configs (reversible).
Converger.Vaultis a Cloak vault usingCloak.Ciphers.AES.GCMwith a 12-byte IV. The key comes fromCLOAK_KEY(base64, 32 bytes);CLOAK_RETIRED_KEYS(comma-separated) lists keys still accepted for decryption.- Each key's cipher tag is derived from its SHA-256 fingerprint (
AES.GCM.<8 hex chars>), so a ciphertext names its own key and rotation needs no tag bookkeeping. channels.secretisConverger.Encrypted.Binaryandchannels.configisConverger.Encrypted.Map; both arebyteaand markedredact: true, so they never appear ininspect/1output or logs.channels.secret_hash(SHA-256, unique index) serves lookups.Channels.get_channel_by_secret/1finds the row by hash and then compares the decrypted secret withPlug.Crypto.secure_compare/2.validate_channel_secret/2and the Metahub.verify_tokencheck also compare in constant time, and a missingverify_tokennow fails.Converger.Vault.encrypt_offline!/1encrypts from app config without the vault process, so the data migration works underRelease.migrate/0.- After a key rotation,
Converger.Release.reencrypt_secrets/0(which callsChannels.reencrypt_all/0) rewrites every row with the current key.
Tenant API keys (one-way).
- New keys are
cvg_live_followed by 43 URL-safe characters (32 random bytes). Onlyapi_key_hash(SHA-256) andapi_key_prefix(cvg_live_plus 4 characters, for display) are stored;Tenant.api_keyis a virtual field set only on the struct returned by create or rotate. Tenants.rotate_api_key/2moves the current hash toprevious_api_key_hashwithprevious_api_key_expires_at(default grace period 24 hours,config :converger, :api_key_rotation_grace_period, or thegrace_period:option) and audits the action asrotate_api_key.Tenants.get_tenant_by_api_key/1accepts the current key, or the previous key until it expires.
Redaction.
Converger.Secrets.redact/1walks maps and lists recursively and replaces the values of sensitive keys with"[REDACTED]"(nilstaysnil). Sensitive keys, compared case-insensitively:access_token,api_key,secret,token,password,password_hash,verify_token,app_secret,authorization,x-api-key,x-channel-token, and anything ending in_secret,_tokenor_hash.Changes.serialize/1applies it and no longer serializes preloaded associations, which could carry their own secrets and are not JSON-encodable. Redacted top-level keys are present with the placeholder, so the audit trail still shows that a secret changed.- The admin UI shows tenant keys as
cvg_live_abcd****and config secrets as****last4(Secrets.mask/1). Full values appear once, in a "copy it now" card, after creation or rotation.
Hashing API keys with plain SHA-256 rather than a password hash (bcrypt, Argon2) is deliberate: the keys carry 256 bits of randomness, so brute force is not a concern, and the lookup runs on every API request.
Consequences
Positive
- Database dumps, backups and read replicas contain only ciphertext and hashes for credentials.
- The audit log for a WhatsApp channel update contains no token values.
- API key rotation has no downtime: the old key keeps working during the grace period.
- Lookups by secret no longer use the plaintext as an index key, and every secret comparison is constant time.
- The same
sensitive_key?/1rules drive audit redaction and UI masking, so they cannot drift apart.
Negative and trade-offs
CLOAK_KEYis required in production and must be set before running migrations, because the data migration encrypts with it. Losing the key loses every channel secret and config; it has to be backed up with the database.- Irreversible migration:
20261008100001_hash_tenant_api_keysdrops the plaintext column. Plaintext keys cannot be restored, and itsdownraisesEcto.MigrationError, so a rollback cannot pass it. - Full API keys can no longer be shown after creation. To hand a key to someone, an admin rotates it.
Tenant.api_keyisnilon loaded tenants; scripts that read it from the database break.- Two channels sharing one secret make the unique
secret_hashindex creation fail during migration (such channels could not authenticate by secret before either). - Audit rows written before the upgrade may still contain plaintext secrets and have to be scrubbed manually.
- Encrypted columns cannot be filtered in SQL (for example "all channels with this phone number id"); such queries have to load and decrypt.
- Two later advisories against
cloak/cloak_ecto(AES-CTR being unauthenticated, PBKDF2 ignoring its iteration count) have no fixed release. They are explicitly ignored in themix.exsaudit config, because onlyAES.GCMand theBinary/Maptypes are used;test/converger/vault_test.exsenforces that.
Follow-ups
- #51: management API with scoped API keys (an
api_keystable replacing the single tenant key) and channel secret rotation over REST. - #50: admin and portal modernization, including key rotation UX.
- #52: auth hardening (token signing keys with
kidand rotation). - Leaked-secret detection at boot and the rotation runbook are covered by ADR-0022.
Implementation
- Vault:
lib/converger/vault.ex(generate_key/0,encrypt_offline!/1,decrypt_offline!/1). - Ecto types:
lib/converger/encrypted/binary.ex,lib/converger/encrypted/map.ex. - Channel schema and lookups:
lib/converger/channels/channel.ex,lib/converger/channels.ex(get_channel_by_secret/1,validate_channel_secret/2,reencrypt_all/0). - Tenant keys:
lib/converger/tenants/tenant.ex,lib/converger/tenants.ex(get_tenant_by_api_key/1,rotate_api_key/2). - Redaction and masking:
lib/converger/secrets.ex,lib/converger/audit_logs/changes.ex. - Release task:
lib/converger/release.ex(reencrypt_secrets/0). - Migrations:
20261008100000_encrypt_channel_secrets.exs(encrypts every row in Elixir, swaps columns, unique index onsecret_hash;downdecrypts back) and20261008100001_hash_tenant_api_keys.exs(PostgreSQL 11+sha256(), keeps the first 4 characters as prefix, dropsapi_key). - Config:
CLOAK_KEYandCLOAK_RETIRED_KEYSinconfig/runtime.exs; fixed dev and test keys inconfig/dev.exsandconfig/test.exs.
Key rotation runbook:
# 1. generate a new key
mix run -e 'IO.puts(Converger.Vault.generate_key())'
# 2. deploy with CLOAK_KEY=<new key> and CLOAK_RETIRED_KEYS=<old key>
# 3. re-encrypt every channel with the new key
bin/converger eval "Converger.Release.reencrypt_secrets()"
# 4. remove CLOAK_RETIRED_KEYS and deploy again
Tests: test/converger/secrets_at_rest_test.exs reads raw rows with Repo.query! and asserts they hold no plaintext and are not JSON, and covers transparent decryption, inspect redaction, hash lookup, rotation with a retired key, reencrypt_all/0, API key format, rotation and grace-period expiry, and the redaction and masking helpers. test/converger/audit_logs_integration_test.exs includes "WhatsApp channel update audit log contains no token values". test/converger/vault_test.exs pins the cipher choice.
Links
- Issue #12, pull request #79
- Security overview
- Deployment and environment variables