Skip to content

crypto: add opt-in compatibility hooks for rolling PBKDF2 upgrades - #1399

Closed
castlec wants to merge 2 commits into
OpenIDC:masterfrom
castlec:crypto/rolling-upgrade-compat-hooks
Closed

castlec wants to merge 2 commits into
OpenIDC:masterfrom
castlec:crypto/rolling-upgrade-compat-hooks

Conversation

@castlec

@castlec castlec commented Oct 15, 2026

Copy link
Copy Markdown

The PBKDF2 change in 2.4.20 makes existing sessions unreadable even when OIDCCryptoPassphrase is unchanged. During a rolling upgrade, immediately issuing PBKDF2-encrypted values also makes upgraded nodes' writes unreadable by old nodes.

This adds two opt-in controls for internal session cookies, login-state cookies, and encrypted cache values:

Phase OIDCCryptoLegacyRead OIDCCryptoLegacyWrite
Deploy patched nodes while old nodes remain On On
Drain all old nodes, then reconfigure and restart patched nodes On Off
Retire compatibility after legacy values expire or are replaced Off Off

Both default to Off. Startup rejects legacy writes without legacy reads. During the second phase, authenticated and validated legacy session cookies are rewritten with the current key without changing their payload, session identifier, or expiry. Other encrypted cache values remain readable and transition through normal writes or expiry.

Temporary behavior lives in jwt_compat.c: an encryption-key hook, a failed-verification hook, and a post-validation persistence hook. The hooks check the configuration internally. The ordinary verifier stays unchanged, and the session layer supplies its private cookie writer as a callback. In a release without an encryption migration, the hooks can become no-ops without changing their callers.

The migration requires the same passphrase and compatible payload schemas and compression settings across nodes. It does not bridge the separate 2.4.19 payload change. Do not reintroduce unpatched nodes after PBKDF2 writes begin; keep legacy reads enabled until all relevant session and cache lifetimes have elapsed. Legacy mode retains the original key-guessing exposure and is intended to be temporary.

Validation:

  • Full make -j2 check: 17 test targets pass, including 23 session tests and 29 configuration tests.
  • Independent legacy key construction/decryption checks compatibility-mode writes; tests cover chunked cookies, passphrase rollover, staged transitions, state cookies, shared encrypted cache values, and rejection after disabling compatibility.
  • Hook tests cover no-op behavior, failed writes without fallback to PBKDF2, and persistence failure/success flag handling.
  • Patch application checked against a clean archive of the base commit. No live multi-server Apache rollout has been exercised.

Related: #1397

Opening as a draft to invite feedback on the hook API and rollout controls before treating this as ready to merge.

Allow a mixed-version pool to keep writing SHA-256-derived internal JWEs
while upgraded nodes accept both legacy and PBKDF2-derived encryption.
After all old nodes are drained, operators can disable legacy writes,
restart, and retain legacy reads until old values expire or are replaced.

Isolate temporary key selection, read fallback, and post-validation session
rewrites in jwt_compat hooks. Cover session cookies, login-state cookies,
and encrypted cache values. Both configuration controls default to Off.

Validate the staged rollout, old-key interoperability, configuration
inheritance, invalid input rejection, and hook persistence failures.
Return NULL when both normal and compatibility verification fail.
Preserve short-circuit fallback while making the verification boundary
explicit and avoiding CI's -Werror=unused-value failure.
@castlec

castlec commented Oct 15, 2026

Copy link
Copy Markdown
Author

The build failure came from discarding the result of the standalone verify(...) || compat_verify(...) expression under -Werror=unused-value.

The proposed fix uses an early-return guard: if both verification paths fail, return NULL before JSON decoding. Compatibility verification still runs only when normal verification fails.

Previously, failed verification left the payload NULL, which the decoder rejected; this makes that rejection explicit rather than relying on the output-pointer convention.

Alternatives:

  • Explicitly discard the result with (void)(verify(...) || compat_verify(...)), then decode unconditionally. This resolves the warning but retains reliance on the decoder rejecting a NULL payload.
  • Decode only inside if (verify(...) || compat_verify(...)), then return the result initialized to NULL. This also prevents decoding after verification failure, using a success condition instead of an early return.

@zandbelt

Copy link
Copy Markdown
Member

Thanks for the work on this, and for the careful write-up and tests.

I'm going to close this as won't-fix. The key derivation change shipped in 2.4.20 on 2026-08-01, and every 2.4.20.x release since has opened its notes with the same warning: sessions created by 2.4.19.x and earlier are invalid after the upgrade. Deployments have had two and a half months and four releases to cross that boundary, and the one-time re-login is the documented cost. Adding two directives and a compatibility layer now would serve a shrinking set of deployments, and would keep the unstretched SHA-256 key path in the tree.

The scope is also narrower than it looks: 2.4.19 changed the internal session format as well, so this would only bridge 2.4.19.x nodes, not anything older.

For a mixed pool upgrading from 2.4.19.x today, the practical options are sticky sessions for the duration of the rollout, or accepting the one-time re-authentication.

@zandbelt zandbelt closed this Oct 15, 2026
@castlec

castlec commented Oct 15, 2026 via email

Copy link
Copy Markdown
Author

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