Skip to content

PMM-14979 Detect and reject a mismatched encryption key - #5735

Draft
ademidoff wants to merge 17 commits into
mainfrom
PMM-14979-share-enc-key-between-nodes
Draft

PMM-14979 Detect and reject a mismatched encryption key#5735
ademidoff wants to merge 17 commits into
mainfrom
PMM-14979-share-enc-key-between-nodes

Conversation

@ademidoff

@ademidoff ademidoff commented Aug 5, 2026

Copy link
Copy Markdown
Member

Ticket number: PMM-14979

Feature build: Percona-Lab/pmm-submodules#4510

Important

Based on #5587 (PMM-15188) to avoid conflicts: both change managed/utils/encryption/encryption.go and managed/models/database.go. Retarget to main once #5587 merges. Review the diff against that base.

Problem

Reported on the forum against a 3-node Docker HA cluster: after pmm-agents reconnected, QAN and postgres_exporter data vanished for several PostgreSQL services, and agents logged

pq: password authentication failed for user "AQ+rKT/93psPSlwWLR8Qb0zsQqLDdhfXYNB9EBYk+507mw1Y"

That "username" is the encrypted value from the agents table. Followers only logged decryption: aead_factory: decryption failed at warning level.

In HA all nodes share one PostgreSQL database, but each reads its encryption key from a local file, and New() in managed/utils/encryption/encryption.go generates one whenever the file is absent. migrateDB encrypts on whichever node migrates first and records the columns in settings.EncryptedItems, so the other nodes skip encryption and keep an unrelated key.

The failure was silent because Decrypt returned the input ciphertext next to the error, and agentEncryption in managed/models/encryption_helpers.go logged a warning and assigned that value anyway. The ciphertext then flowed through Agent.DSN (managed/models/agent_model.go:543) into SetState.

A worse variant was reachable: if a follower won the migration race on a fresh database, the leader's later writes used a different key, leaving one table encrypted under two keys, which pmm-encryption-rotation cannot repair.

Changes

Fail closed rather than degrade

  • Decrypt and Encrypt return an empty string on failure, so a caller that ignores the error cannot pass ciphertext off as plaintext or persist a secret unencrypted. The ciphertext is no longer interpolated into the base64 error message, which was putting secret material in logs.
  • EncryptAgent/DecryptAgent return an error; all 20 call sites in managed/models/agent_helpers.go, managed/services/agents/service_info_broker.go and managed/services/realtimeanalytics/service.go propagate it. Errors name the agent and column.

Detect a mismatched key

  • Settings.EncryptionKeyFingerprint plus encryption.Fingerprint(). Recorded in dbEncryption in the same transaction that marks the columns encrypted, so a concurrently starting node cannot see encrypted data with no fingerprint and adopt its own key. Cleared on the decrypt path, which keeps key rotation (decrypt -> new key -> encrypt) correct.
  • models.VerifyEncryptionKey compares the local key at startup. Under HA a mismatch is fatal: the remedy is copying one file, and continuing means handing out unusable credentials. Standalone PMM logs it and sets pmm_managed_encryption_key_mismatch, so an installation whose key went missing does not stop booting on upgrade. Databases with no fingerprint adopt the local key only if it decrypts what is already stored.

Prevent it at the source

  • pmm-managed-init refuses to start an HA node with no key file instead of letting one be generated per node. build/docker/server/entrypoint.sh runs under set -o errexit, so the non-zero exit does abort startup.

Documentation

  • documentation/docs/admin/security/data_encryption.md described the key as 32 raw bytes; the code writes a base64-encoded Tink keyset, so anyone following it to pre-provision a shared key would have hit a startup failure. Corrected, with an HA section.
  • Added key handling to documentation/docs/install-pmm/install-HA-clustered.md (which had no mention of encryption) and a warning to the Docker PMM_HA_* table in documentation/docs/install-pmm/install-pmm-server/deployment-options/docker/preview_env_var.md.

Related

Testing

Added: encrypt/decrypt round trip and a regression test that a value this node cannot decrypt surfaces as an error instead of being returned (managed/models/encryption_helpers_test.go); five fingerprint cases via sqlmock, including the reported follower scenario (managed/models/encryption_key_test.go); the HA preflight (managed/cmd/pmm-managed-init/main_test.go).

go build ./... and go vet ./... clean, golangci-lint reports 0 new issues against the base.

Not verified locally: testdb-backed suites need /srv/.postgres_password, so they fail with pq: password authentication failed here. Failure counts are identical on this branch and the base, but TestDefaultAgentEncryptionColumnsRoundTrip and the realtimeanalytics suite are unexercised against these changes and need CI.

claude and others added 10 commits July 29, 2026 13:50
…columns

Encryption key rotation re-encrypted the JSON "option" columns
(mysql_options, mongo_options, aws_options, azure_options,
postgresql_options) during the DECRYPT phase instead of decrypting them,
adding another encryption layer on every rotation.

Root cause: encryption.Column exposed a single CustomHandler that was
wired to the Encrypt* handler in DefaultAgentEncryptionColumnsV3. Both
EncryptItems and DecryptItems invoked that same handler, so the decrypt
pass of a rotation actually encrypted the values again. Each rotation
therefore stacked two extra layers (decrypt-phase encrypt + encrypt-phase
encrypt), growing tls_cert/tls_key ~80% per cycle until pmm-agent could
no longer parse them ("tls: failed to find any PEM data in certificate
input") and MySQL TLS monitoring broke.

Fix: give Column separate CustomEncryptHandler and CustomDecryptHandler
fields. EncryptItems uses the encrypt handler, DecryptItems uses the
decrypt handler, and DefaultAgentEncryptionColumnsV3 wires both. The
already-present but previously unused Decrypt*OptionsHandler functions are
now correctly used during the decrypt phase.

Also extends TestEncryptionRotation to store real encrypted PEM cert/key
in mysql_options and assert they still decrypt to the original PEM across
two consecutive rotations. The test fails on the old code and passes with
the fix.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Enforce the encrypt/decrypt handler pair invariant documented on
encryption.Column with a generic round trip over every column of
DefaultAgentEncryptionColumnsV3, so a column wired with a single handler
for both directions fails a test instead of silently double-encrypting.

Also harden the rotation test: use a real self-signed PEM key pair and
assert it still loads via tls.X509KeyPair after rotation, drop the
dependency on when the lazily initialized default encryption is created,
remove the key files in t.Cleanup, and run the second rotation through
rotateEncryptionKey to skip the supervisorctl round trips.
In HA every PMM Server node shares one PostgreSQL database but reads its
encryption key from a local file. A node that generated its own key could
not decrypt the credentials stored by the others, and that failure was
silent: Decrypt returned the input ciphertext alongside the error, and
agentEncryption logged a warning and assigned that value anyway. The
ciphertext then travelled through Agent.DSN into SetState, so pmm-agent
received it in place of a username and reported

    pq: password authentication failed for user "AQ+rKT/93psPS..."

while QAN and postgres_exporter data disappeared for the affected
services.

Fail closed instead of degrading:

- Decrypt and Encrypt now return an empty string on failure, so a caller
  that ignores the error cannot pass ciphertext off as plaintext, nor
  persist a secret unencrypted. The ciphertext is also no longer included
  in the base64 error message, which put secret material in the log.
- EncryptAgent and DecryptAgent return an error, and the call sites in
  models, service_info_broker and realtimeanalytics propagate it. The
  error names the agent and the column, so a bad row can be identified.
- Settings gains EncryptionKeyFingerprint, recorded in the same
  transaction that marks the columns encrypted. A node compares its own
  key against it at startup: under HA a mismatch is fatal, since the
  remedy is copying one file and continuing would hand out unusable
  credentials and write rows the other nodes cannot read. Standalone PMM
  logs the problem and exposes pmm_managed_encryption_key_mismatch so an
  installation whose key went missing does not stop booting on upgrade.
  Databases with no fingerprint recorded adopt the local key only if it
  decrypts what is already stored.
- pmm-managed-init refuses to start an HA node that has no key, rather
  than letting one be generated per node.

The documentation described the key file as 32 raw bytes, but the code
writes a base64-encoded Tink keyset, so anyone following it to
pre-provision a shared key would have hit a startup failure. It now
documents the real format, how to generate a key, and that HA nodes must
share one.
Base automatically changed from PMM-15188 to main August 11, 2026 17:07
…ey-between-nodes

Signed-off-by: Alex Demidoff <[email protected]>

# Conflicts:
#	managed/models/encryption_helpers_test.go
Extract decryptAgents/insertAgent helpers in models, reuse UpdateAgent in
the service layer, and turn agentEncryption's copy-pasted per-field blocks
into a field table. Gate the no-fingerprint key probe on agents.username
actually being encrypted, and trim comments to the intent.

Signed-off-by: Alex Demidoff <[email protected]>
Three ChangeAgent subtests inserted agents with plaintext credentials, and
TestListSessions wrote a decrypted agent back with db.Update, so decryption
rejected the rows once it started returning an error instead of a warning.

Signed-off-by: Alex Demidoff <[email protected]>
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.78836% with 76 lines in your changes missing coverage. Please review.
✅ Project coverage is 45.23%. Comparing base (31318c7) to head (9298afa).
⚠️ Report is 111 commits behind head on main.

Files with missing lines Patch % Lines
managed/models/agent_helpers.go 56.52% 12 Missing and 8 partials ⚠️
managed/utils/encryption/encryption.go 0.00% 17 Missing ⚠️
managed/cmd/pmm-managed/main.go 0.00% 14 Missing ⚠️
managed/models/encryption_helpers.go 83.95% 7 Missing and 6 partials ⚠️
managed/cmd/pmm-managed-init/main.go 64.70% 6 Missing ⚠️
managed/models/database.go 66.66% 1 Missing and 1 partial ⚠️
managed/services/agents/service_info_broker.go 0.00% 2 Missing ⚠️
managed/utils/encryption/helpers.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5735      +/-   ##
==========================================
+ Coverage   43.59%   45.23%   +1.63%     
==========================================
  Files         415      218     -197     
  Lines       43134    27914   -15220     
==========================================
- Hits        18804    12626    -6178     
+ Misses      22454    13905    -8549     
+ Partials     1876     1383     -493     
Flag Coverage Δ
admin ?
agent ?
managed 45.23% <59.78%> (+2.25%) ⬆️
vmproxy ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants