Skip to content

fix(wire): decrypt reads on pgjdbc describe-statement and prepared-reuse flows - #2

Merged
Andrei Kvapil (kvaps) merged 7 commits into
mainfrom
fix/verify-email-read-path
Jul 8, 2026
Merged

fix(wire): decrypt reads on pgjdbc describe-statement and prepared-reuse flows#2
Andrei Kvapil (kvaps) merged 7 commits into
mainfrom
fix/verify-email-read-path

Conversation

@kvaps

@kvaps Andrei Kvapil (kvaps) commented Jul 8, 2026

Copy link
Copy Markdown
Member

Problem

On stage, Keycloak 26.6.3 behind proxy v0.2.0 received raw $KKP$… ciphertext instead of decrypted PII on warm connections: verifyEmail failed with EmailException: Invalid address '$KKP$…', and the same blobs surfaced in id_token email claims, breaking OIDC logins.

Root cause

The decrypt-on-DataRow plan was built only when a RowDescription arrived while its portal was executing. Real pgjdbc traffic violates that: a server-prepared statement (past prepareThreshold) is described as a statement — its RowDescription arrives outside any Execute and was dropped — and warm re-executions send bare Bind/Execute with no Describe, so no RowDescription flows at all. Simple-protocol queries were untracked, so their CommandComplete desynced the result queue, and clients pipeline the next batch before consuming the previous ReadyForQuery.

Fix

  • Track frontend Describes in a FIFO, attribute RowDescription/NoData answers to the described statement or portal, cache result columns on the statement, and derive portal plans from that cache when the portal itself was never described.
  • Track simple-protocol queries as synthetic result-queue entries, planned like Parse (fail-loud on literal PII writes, decrypt on reads).
  • Track Sync boundaries: ErrorResponse drops only the failing batch, ReadyForQuery closes exactly one batch.
  • Fail loudly (new kkp_ciphertext_passthrough_total counter + WARN) when an unplanned DataRow still carries a ciphertext envelope; PII-table WARN matching now respects identifier boundaries.
  • Inventory double-encrypted rows (ciphertext written back as a value during a passthrough window and encrypted again) via kkp_double_encrypted_total + WARN on decrypt.

Testing

  • make lint / make test green; regression tests cover the describe-statement flow, server-prepared reuse, pipelined batches across ReadyForQuery, error recovery, simple-query cycles, NoData attribution, PortalSuspended, and double-encrypted row detection.
  • Verified on stage: freshly registered users' emails arrive decrypted in id_token claims; passthrough counter stays at zero.

Summary by CodeRabbit

  • New Features

    • Added support for handling multi-statement SQL queries more reliably.
    • Improved observability for suspicious encrypted data cases during reads.
  • Bug Fixes

    • Reduced race conditions during server shutdown and new connection acceptance.
    • Improved routing of query results so pipelined and prepared-statement flows stay in sync.
    • Added safer handling for unreadable or partially processed encrypted values, including double-encrypted records.

A simple-protocol query ('Q') may carry several semicolon-separated
statements, each answered by its own result cycle. Expose a per-statement
analysis so the wire layer can track one result-queue entry per statement.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
The decrypt-on-DataRow plan was only built when a RowDescription arrived
while its portal was executing. pgjdbc violates that assumption twice:
once a statement is server-prepared (prepareThreshold) it is described as
a statement — the RowDescription arrives outside any Execute and was
dropped — and warm re-executions send bare Bind/Execute with no Describe
at all, so no RowDescription flows. Both left the portal without a read
plan and raw ciphertext envelopes went to Keycloak (the stage
verify-email incident: EmailException on a $KKP$ blob in the address,
and the same blob surfacing in id_token email claims).

Track frontend Describes in a FIFO and attribute each backend
RowDescription/NoData answer to the described statement or portal,
caching the result columns on the statement; derive a portal's plan from
that cache when the portal itself was never described.

Track simple-protocol queries ('Q') as synthetic result-queue entries —
their untracked CommandComplete used to pop a pending extended-protocol
portal and desync every later result on the connection — planning them
like OnParse (fail-loud on literal PII writes, decrypt on reads).

Track Sync boundaries in both queues: clients pipeline the next batch
before consuming the previous batch's ReadyForQuery, so ErrorResponse
drops only the failing batch's entries (the backend skips to Sync) and
ReadyForQuery closes exactly one batch, dropping dead entries that never
produced results. Advance past PortalSuspended, and fail loudly —
counter plus WARN — whenever a DataRow that no plan matched still
carries a ciphertext envelope. PII-table WARN matching now respects
identifier boundaries so RESET_CREDENTIALS_FLOW no longer counts as
CREDENTIAL.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
Cover the pgjdbc describe-statement and server-prepared reuse flows,
batches pipelined across an unconsumed ReadyForQuery, error recovery
that keeps the pipelined batch, the simple-protocol
select/COMMIT/multi-statement cycles and queue sync, NoData describe
attribution, PortalSuspended resume, and the PII-warn
identifier-boundary false positive.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
A value that decrypts into another ciphertext envelope is a corrupted
row: ciphertext leaked to the client during a passthrough window and was
written back as the value (Keycloak lowercases username/email on write,
so the inner envelope is case-mangled and unrecoverable from that
column). The proxy keeps decrypting one layer only and now counts such
rows in kkp_double_encrypted_total with a WARN, so operators can find
and clean them up.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes a shutdown race in the proxy's accept loop, adds multi-statement SQL analysis (AnalyzeAll), reworks the wire session state machine to correctly attribute RowDescription/Describe/Query/Sync results across pipelined and simple-protocol flows, adds ciphertext-leak and double-encryption detection with new Prometheus metrics, and includes extensive pgjdbc regression tests.

Changes

Proxy Decrypt Pipeline and Protocol Correctness

Layer / File(s) Summary
Accept-loop shutdown race fix
cmd/proxy/server.go
serve() snapshots the listener under lock and checks shutdown state before registering accepted connections; close() clears the listener under lock to signal shutdown safely.
Multi-statement SQL analysis
internal/rewrite/analyze.go, internal/rewrite/analyze_test.go
Analysis gains a SQL field; Analyze delegates to a shared analyzeNode; new AnalyzeAll/statementText parse and slice per-statement SQL for multi-statement simple-protocol queries, validated by a new test.
Session queue rework for Describe/Query/Sync attribution
internal/wire/session.go
Adds a describes queue, pendingDescribe/syncBoundary types, PreparedStatement.Columns cache, OnDescribe/OnQuery/OnSync implementations, rewritten OnRowDescription with learnColumns/planRead/ensureReadPlan, identifier-aware PII matching, and boundary-aware error/ready-for-query handling.
Relay message routing
internal/wire/relay.go
observeFrontend/observeBackend switches add explicit routing for Describe, Sync, Query, NoData, PortalSuspended, and ReadyForQuery.
Ciphertext leak and double-encryption detection
internal/wire/transform.go, internal/observe/metrics.go
DecryptDataRow flags passthrough ciphertext via flagCiphertextLeak and detects double-encrypted values; new CiphertextPassthrough and DoubleEncrypted Prometheus counters are registered.
pgjdbc regression tests and dependency
internal/wire/pgjdbc_flow_test.go, go.mod
New tests cover prepared-statement reuse, simple-protocol queries, pipelined batches, error handling, PII boundary detection, and double-encryption metrics; adds an indirect test dependency.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Frontend
  participant Session
  participant Backend
  Frontend->>Session: OnDescribe(statement/portal)
  Session->>Session: enqueue pendingDescribe
  Frontend->>Session: OnQuery(sql)
  Session->>Session: AnalyzeAll(sql), enqueue synthetic entries
  Frontend->>Session: OnSync
  Session->>Session: push syncBoundary onto execQueue/describes
  Backend->>Session: OnRowDescription
  Session->>Session: pop pendingDescribe or fallback to executing portal
  Session->>Session: learnColumns, ensureReadPlan
  Backend->>Session: DataRow
  Session->>Session: DecryptDataRow via ensureReadPlan
  alt leak or double-encrypted
    Session->>Session: flagCiphertextLeak / increment DoubleEncrypted
  end
Loading
sequenceDiagram
  participant Client
  participant serve
  participant close
  close->>serve: s.listener = nil (under s.mu)
  serve->>serve: Accept() on snapshotted listener
  serve->>serve: check s.listener == nil
  alt shutdown detected
    serve->>Client: close accepted connection
  else active
    serve->>serve: register connection, s.wg.Add(1)
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main wire-protocol decrypt fix for pgjdbc describe-statement and prepared-reuse flows.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/verify-email-read-path

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for tracking simple-protocol queries and server-prepared statement reuse in the KMS proxy, along with new metrics for tracking ciphertext passthrough and double-encrypted rows, and comprehensive regression tests. The review feedback highlights critical memory leaks across several queue-popping operations in session.go where sliced elements containing pointers are not zeroed out, preventing garbage collection of PreparedStatement and Portal objects. Additionally, it is recommended to extract individual statement SQL in multi-statement simple queries to prevent false positives in PII detection.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread internal/wire/session.go Outdated
Comment on lines +270 to +271
pd := s.describes[0]
s.describes = s.describes[1:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Popping an element from s.describes by slicing (s.describes[1:]) leaves the popped element's pointers (stmt and portal) in the underlying array. This prevents the garbage collector from reclaiming the referenced PreparedStatement and Portal objects, leading to memory leaks in long-running proxy sessions. Explicitly zero out the popped element before slicing to allow GC to reclaim the memory.

		pd := s.describes[0]
		s.describes[0] = pendingDescribe{}
		s.describes = s.describes[1:]

Comment thread internal/wire/session.go
Comment on lines +342 to 344
if len(s.describes) > 0 && !s.describes[0].boundary {
s.describes = s.describes[1:]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Popping an element from s.describes by slicing without zeroing out the popped element leaves the pointers in the underlying array, causing a memory leak. Explicitly zero out the popped element before slicing.

	if len(s.describes) > 0 && !s.describes[0].boundary {
		s.describes[0] = pendingDescribe{}
		s.describes = s.describes[1:]
	}

Comment thread internal/wire/session.go
Comment on lines +413 to +420
func (s *Session) OnErrorResponse() {
for len(s.execQueue) > 0 && s.execQueue[0] != syncBoundary {
s.execQueue = s.execQueue[1:]
}
for len(s.describes) > 0 && !s.describes[0].boundary {
s.describes = s.describes[1:]
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When clearing the queues on error, slicing s.execQueue and s.describes without zeroing out the popped elements leaves references to Portal and PreparedStatement objects in the underlying arrays, causing memory leaks. Explicitly set them to nil or zero value before slicing.

Suggested change
func (s *Session) OnErrorResponse() {
for len(s.execQueue) > 0 && s.execQueue[0] != syncBoundary {
s.execQueue = s.execQueue[1:]
}
for len(s.describes) > 0 && !s.describes[0].boundary {
s.describes = s.describes[1:]
}
}
func (s *Session) OnErrorResponse() {
for len(s.execQueue) > 0 && s.execQueue[0] != syncBoundary {
s.execQueue[0] = nil
s.execQueue = s.execQueue[1:]
}
for len(s.describes) > 0 && !s.describes[0].boundary {
s.describes[0] = pendingDescribe{}
s.describes = s.describes[1:]
}
}

Comment thread internal/wire/session.go
Comment on lines +425 to +442
func (s *Session) OnReadyForQuery() {
dead := 0
for len(s.execQueue) > 0 {
head := s.execQueue[0]
s.execQueue = s.execQueue[1:]
if head == syncBoundary {
break
}
dead++
}
for len(s.describes) > 0 {
head := s.describes[0]
s.describes = s.describes[1:]
if head.boundary {
break
}
dead++
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When consuming the sync boundary and dead entries on ReadyForQuery, slicing s.execQueue and s.describes without zeroing out the popped elements causes memory leaks. Explicitly set them to nil or zero value before slicing.

func (s *Session) OnReadyForQuery() {
	dead := 0
	for len(s.execQueue) > 0 {
		head := s.execQueue[0]
		s.execQueue[0] = nil
		s.execQueue = s.execQueue[1:]
		if head == syncBoundary {
			break
		}
		dead++
	}
	for len(s.describes) > 0 {
		head := s.describes[0]
		s.describes[0] = pendingDescribe{}
		s.describes = s.describes[1:]
		if head.boundary {
			break
		}
		dead++
	}

Comment thread internal/wire/session.go
Comment on lines 448 to 452
func (s *Session) popExecuting() {
if len(s.execQueue) > 0 {
if len(s.execQueue) > 0 && s.execQueue[0] != syncBoundary {
s.execQueue = s.execQueue[1:]
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Popping the executing portal by slicing s.execQueue without zeroing out the popped element leaves a reference to the Portal in the underlying array, causing a memory leak. Explicitly set the popped element to nil before slicing.

Suggested change
func (s *Session) popExecuting() {
if len(s.execQueue) > 0 {
if len(s.execQueue) > 0 && s.execQueue[0] != syncBoundary {
s.execQueue = s.execQueue[1:]
}
}
func (s *Session) popExecuting() {
if len(s.execQueue) > 0 && s.execQueue[0] != syncBoundary {
s.execQueue[0] = nil
s.execQueue = s.execQueue[1:]
}
}

Comment thread internal/wire/session.go
Comment on lines +228 to +231
s.execQueue = append(s.execQueue, &Portal{
Name: simplePortalName,
Stmt: &PreparedStatement{SQL: q.String, Analysis: a, WritePlan: plan},
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In a multi-statement simple query, using the entire query string q.String as the SQL for every statement's PreparedStatement can cause false positives in piiTouchedButNotPlanned and inaccurate log messages. For example, if one statement in the batch touches PII but another does not, the non-PII statement's check will still see the PII table name in the full query string. Consider extracting the individual statement's SQL from q.String using the statement's location and length from the parser.

In a multi-statement simple query every synthetic entry carried the full
query string, so per-statement logging and the PII-warn heuristic saw the
neighbouring statements too. Slice each statement's own text out of the
query string via the parser's location/length.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
Popping by reslicing left the popped Portal/PreparedStatement pointers in
the backing array, pinning them from GC for the life of the connection.
Route every pop through helpers that zero the slot first.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
serve() read s.listener without the lock, and a connection accepted
concurrently with close() could wg.Add(1) after close() had started
wg.Wait() — a data race the race detector flagged intermittently in
TestServerStartAcceptStop. Snapshot the listener under the lock, register
accepted connections under the same lock close() takes before waiting,
and drop a connection that loses the race with shutdown.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>

@sircthulhu Kirill Ilin (sircthulhu) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Focused fix for a real incident (raw $KKP$ ciphertext reaching Keycloak's verifyEmail and id_token claims). Reviewed the diff against the merge-base and validated in a throwaway worktree.

Validation

  • go build ./... — clean
  • go test ./... — all packages pass
  • go test -race -count=3 ./cmd/proxy/... ./internal/wire/... — clean (matters, since this fixes a data race)
  • make lint (pinned golangci-lint) — 0 issues

Why it's sound

The reworked two-queue wire state machine (execQueue + describes, each with syncBoundary markers) holds up against protocol-realistic flows:

  • Describe-statement + warm reuse: columns cached on the PreparedStatement at Describe time; ensureReadPlan lazily derives the portal plan on bare Bind/Execute cycles — the actual verify-email fix.
  • FIFO attribution of RowDescription/NoData to the head non-boundary describe is valid (Execute never emits RowDescription).
  • Pipelining/boundaries: each Sync pushes exactly one boundary to both queues, each ReadyForQuery pops one — the queues stay in lockstep across cross-batch pipelining, error-in-batch-1, prefetch-then-execute, and interleaved simple/extended traffic.
  • Fail-loud consistency: simple-protocol queries are now planned identically to OnParse — a literal PII write or a literal deterministic-PII filter returns ErrUnencryptablePII and tears down the connection rather than passing ciphertext or returning wrong rows.

server.go race fix is correct: serve() snapshots the listener under the lock, and wg.Add(1) is atomic with the listener == nil check under the same mutex close() takes before wg.Wait(). Semaphore token is balanced on the drop path. Race detector confirms.

Observability additions (kkp_ciphertext_passthrough_total, kkp_double_encrypted_total) are low-cardinality and correctly wired.

Non-blocking suggestions

  1. DecryptDataRow logs a WARN per double-encrypted field per row — during an incident where Keycloak polls a corrupted column this can flood logs. The metric alone suffices; consider dropping to debug or rate-limiting.
  2. The literal-write fail-loud is tested (TestSimpleQueryLiteralPIIWriteFailsLoud), but the literal-filter fail-loud (WHERE email = 'literal'ErrUnencryptablePII) is newly reachable for simple queries and isn't directly covered. Low risk (shared PlanWrite path), but a one-line test would document the stricter behavior.

@kvaps
Andrei Kvapil (kvaps) marked this pull request as ready for review July 8, 2026 16:23
@kvaps
Andrei Kvapil (kvaps) merged commit c165cd8 into main Jul 8, 2026
3 of 4 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/wire/transform.go (1)

250-274: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

flagCiphertextLeak adds a per-value string conversion to the hot path for every non-PII passthrough row.

flagCiphertextLeak is called for every DataRow on the "empty-plan" path — i.e., every row of every query against a table with no PII columns, which is likely the majority of proxy traffic. For each column value it does a full string(v) copy before crypto.Parse even checks the cheap prefix. This adds allocation/copy overhead to the common case rather than only the rare "actually leaking ciphertext" case.

Consider a byte-level prefix check before allocating a string, so only genuinely suspicious values pay the crypto.Parse cost:

♻️ Suggested optimization
+// (in internal/crypto, export the sentinel bytes or add a cheap check)
+// func HasEnvelopePrefix(v []byte) bool { return bytes.HasPrefix(v, sentinelBytes) }
+
 func flagCiphertextLeak(dr *pgproto3.DataRow, reason string) {
 	for _, v := range dr.Values {
-		if v == nil {
+		if v == nil || !crypto.HasEnvelopePrefix(v) {
 			continue
 		}
 		if _, ok, err := crypto.Parse(string(v)); ok || err != nil {
 			observe.CiphertextPassthrough.WithLabelValues(reason).Inc()
 			log.Printf("kkp: WARN ciphertext passed through undecrypted (%s) — read-path gap", reason)
 			return
 		}
 	}
 }

Also applies to: 304-319

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/wire/transform.go` around lines 250 - 274, The hot path in
Session.DecryptDataRow is paying an unnecessary allocation cost through
flagCiphertextLeak for every passthrough DataRow, especially on the empty-plan
branch. Update flagCiphertextLeak (and any shared callers like the no-portal
path) to do a cheap byte-level ciphertext prefix check before converting each
value to a string or calling crypto.Parse, so only genuinely suspicious values
incur the parsing/allocation overhead.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/wire/transform.go`:
- Around line 250-274: The hot path in Session.DecryptDataRow is paying an
unnecessary allocation cost through flagCiphertextLeak for every passthrough
DataRow, especially on the empty-plan branch. Update flagCiphertextLeak (and any
shared callers like the no-portal path) to do a cheap byte-level ciphertext
prefix check before converting each value to a string or calling crypto.Parse,
so only genuinely suspicious values incur the parsing/allocation overhead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 966da047-9cca-448f-83c6-904340e501dc

📥 Commits

Reviewing files that changed from the base of the PR and between 027a116 and 5f4d3de.

📒 Files selected for processing (9)
  • cmd/proxy/server.go
  • go.mod
  • internal/observe/metrics.go
  • internal/rewrite/analyze.go
  • internal/rewrite/analyze_test.go
  • internal/wire/pgjdbc_flow_test.go
  • internal/wire/relay.go
  • internal/wire/session.go
  • internal/wire/transform.go

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants