Skip to content

CAMEL-25796: camel-pqc - persist the stateful key index before the signature is published - #28099

Merged
davsclaus merged 2 commits into
apache:mainfrom
oscerd:fix/CAMEL-25796-pqc-stateful-index-order
Oct 22, 2026
Merged

davsclaus merged 2 commits into
apache:mainfrom
oscerd:fix/CAMEL-25796-pqc-stateful-index-order

Conversation

@oscerd

@oscerd oscerd commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Fixes CAMEL-25796.

Stacked on #28094. This branch is based on fix/CAMEL-25797-pqc-rotation-signals, so until that merges this PR
shows two commits. Both change PQCProducer.recordKeyUsageAfterSign and its two call sites, so branching from
main would have guaranteed a conflict. Review only the CAMEL-25796: commit here; the diff collapses to that
commit alone once #28094 lands. Merge #28094 first.

Problem

sign() consumes the one-time index inside an LMS, HSS, XMSS or XMSSMT private key. The signature was published
first and the advanced key persisted afterwards:

    signature = signerForExchange.sign();
}
exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);

recordKeyUsageAfterSign(exchange);

A crash, a kill -9, or any failure inside the persist step leaves an emitted signature in the world while the
stored key still sits at the old index. On restart the key resumes there and signs a second time with an index
already in use — exactly what the one-time property of these schemes forbids.

Three further paths skipped persistence silently for a stateful key: no keyLifecycleManager, no CamelPQCKeyId
header, and no stored metadata. Only the last one warned.

Fix

The order is reversed in both signature() and hybridSignature() — key state recorded first, signature headers
set afterwards.

The window cannot be closed completely: BouncyCastle advances the index inside sign() and exposes no
reserve-then-sign API, so a true reservation is not available. What this buys is that a persistence failure now
fails the exchange instead of releasing a signature whose index advance was never stored. Losing a signature is
recoverable; reusing a one-time index is not.

The three silent paths now log a warning naming the specific reason, once per producer so a high-volume route does
not flood the log.

Tests

New PQCStatefulKeyPersistOrderTest, using an InMemoryKeyLifecycleManager whose storeKey can be made to fail
(standing in for an unreachable vault):

  • aFailureToPersistTheIndexFailsTheExchange
  • theSignatureIsNotPublishedWhenPersistenceFails — the discriminating test: against the pre-change baseline a
    signature is published despite the failure
  • aSuccessfulSignStillPersistsAndPublishes — guards the happy path, asserting exactly one storeKey and a usage
    count of 1

Full camel-pqc build green: 244 tests, 0 failures, no regenerated-file drift.

Upgrade guide

Added a 4.24 entry — an exchange that previously returned a signature with unpersisted key state now fails, which is
a visible behaviour change even though it is the safer outcome.


Reported, analysed and fixed by Claude Code (AI agent) on behalf of @oscerd.

🤖 Generated with Claude Code

@oscerd
oscerd requested review from davsclaus and gnodet October 19, 2026 09:59

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: persist-before-publish ordering + usage-count tracking

Ordering fix ✅ — recordKeyUsageAfterSign() is now called before setHeader(PQCConstants.SIGNATURE, …) in both signature() and hybridSignature(). The stateful key index (and usageCount) is therefore persisted before the signature is visible to the caller. Correct.

Test ✅ — The new test correctly discriminates the ordered from the out-of-order path.

Inline comment — one gap spotted in the usage-count path (see below).

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@oscerd
oscerd force-pushed the fix/CAMEL-25796-pqc-stateful-index-order branch from 4a986d9 to 1b7f1bc Compare October 19, 2026 11:36

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The persist-before-publish reordering is correct, the previous finding on non-stateful key warning coverage is resolved, and no new issues were introduced. ✅

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @oscerd. Reviewing the two CAMEL-25796 commits only, as you asked. Moving the persist before the headers is right. The gnodet-bot thread is addressed by warnKeyUsageNotRecorded, and I resolved it.

Three things to change:

  1. The warning for stateless keys without a manager (inline). See the inline comment on PQCProducer.java.

  2. What changes, in the upgrade guide and the description (inline). Before this PR a persist failure already failed the exchange: recordKeyUsageAfterSign threw out of signature(). What changes is that the failed message no longer carries the signature headers, so an onException(...).handled(true) or a dead letter channel can no longer pass the signature on. The same applies to a crash (kill -9): the header set before the persist never left the processor, so the order does not change that case. Please describe the error-handling case instead. Also, the last paragraph of the entry still speaks of stateful keys only, while the warning now covers every key.

  3. Stateless keys and the new order. With #28094 a stateless key records its usage too, so after this reordering a vault error while counting an ML-DSA signature drops the signature and fails the exchange. Losing the signature is the right trade for a one-time index, but not for a usage count. As on #28094, I would only fail the exchange for a stateful key, and log for the others.

The tests are clear. theSignatureIsNotPublishedWhenPersistenceFails is the one that fails on the base. CI is still running on 1b7f1bc.

Claude Code on behalf of Claus Ibsen

Comment thread docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc Outdated
@oscerd
oscerd force-pushed the fix/CAMEL-25796-pqc-stateful-index-order branch from 1b7f1bc to c38801d Compare October 19, 2026 13:27
@oscerd
oscerd requested a review from davsclaus October 19, 2026 13:40

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, both threads are fixed and I resolved them:

  • With no keyLifecycleManager, only a stateful key warns now, so a plain pqc:sign stays quiet.
  • The upgrade note now says the right thing: the exchange already failed, and what is new is that the failed message no longer carries the signature headers.

One part of the third point is still open. For a stateless key, the updateKeyMetadata write is now caught and logged, but the klm.getKeyMetadata(keyId) read before it is not. On main, a stateless key returns before any call to the manager. With this stack, a vault that cannot be read fails the exchange. Because the usage is now recorded before the header is set, that exchange also loses a good signature. Catching the read for a stateless key, the same way as the write, would close it. That code comes from 28094, so the fix may belong there.

This branch now carries the three commits of #28094 (CAMEL-25797). Please merge it after #28094, or rebase it once #28094 is on main, so this PR only shows its own commit.

Claude Code on behalf of Claus Ibsen

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-pqc
  • docs

🔬 Scalpel shadow comparison — Scalpel: 8 of 707 tested, 26 compile-only — current: 8 all tested

Maveniverse Scalpel detected 8 affected modules (current approach: 8).

Skip-tests mode would test 8 modules (2 direct + 7 downstream), skip tests for 26 (generated code, meta-modules)

Modules Scalpel would test (8)
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-pqc ← components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java, components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyPersistOrderTest.java
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (26)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

All tested modules (35 modules, 5m 33s total)

Total reactor time: 5m 33s

Module Duration Status
Camel :: Launcher 51.1s SUCCESS
Camel :: JBang :: MCP 43.7s SUCCESS
Camel :: JBang :: Plugin :: Validate 29.3s SUCCESS
Camel :: Catalog :: Camel Catalog 25.2s SUCCESS
Camel :: YAML DSL 23.5s SUCCESS
Camel :: YAML DSL :: Validator 23.4s SUCCESS
Camel :: Component DSL 19.1s SUCCESS
Camel :: Docs 15.4s SUCCESS
Camel :: JBang :: Plugin :: Testing 14.2s SUCCESS
Camel :: Kamelet Main 13.5s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 13.3s SUCCESS
Camel :: Catalog :: Camel Route Parser 8.9s SUCCESS
Camel :: YAML DSL :: Deserializers 7.6s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 7.2s SUCCESS
Camel :: Assembly 5.9s SUCCESS
Camel :: All Components Sync point 5.5s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 4.4s SUCCESS
Camel :: YAML DSL :: Maven Plugins 4.0s SUCCESS
Camel :: Catalog :: Maven 3.1s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.6s SUCCESS
Camel :: Catalog :: Dummy Component 1.7s SUCCESS
Camel :: JBang :: Main 1.5s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.4s SUCCESS
Camel :: Catalog :: Console 1.3s SUCCESS
Camel :: Coverage 1.3s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.3s SUCCESS
Camel :: Endpoint DSL :: Support 1.1s SUCCESS
Camel :: JBang :: Integration tests 1.0s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.9s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: JBang :: Plugin :: TUI n/a
Camel :: PQC n/a

Top 20 slowest modules:

  • Camel :: Launcher (51.1s)
  • Camel :: JBang :: MCP (43.7s)
  • Camel :: JBang :: Plugin :: Validate (29.3s)
  • Camel :: Catalog :: Camel Catalog (25.2s)
  • Camel :: YAML DSL (23.5s)
  • Camel :: YAML DSL :: Validator (23.4s)
  • Camel :: Component DSL (19.1s)
  • Camel :: Docs (15.4s)
  • Camel :: JBang :: Plugin :: Testing (14.2s)
  • Camel :: Kamelet Main (13.5s)
  • Camel :: JBang :: Plugin :: Kubernetes (13.3s)
  • Camel :: Catalog :: Camel Route Parser (8.9s)
  • Camel :: YAML DSL :: Deserializers (7.6s)
  • Camel :: Catalog :: Camel Report Maven Plugin (7.2s)
  • Camel :: Assembly (5.9s)
  • Camel :: All Components Sync point (5.5s)
  • Camel :: YAML DSL :: Validator Maven Plugin (4.4s)
  • Camel :: YAML DSL :: Maven Plugins (4.0s)
  • Camel :: Catalog :: Maven (3.1s)
  • Camel :: Catalog :: Suggest (deprecated) (2.6s)

⚙️ View full build and test results

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for this PR — the overall approach is solid. One bug to address before merge:

In recordKeyUsageAfterSign, the call to klm.getKeyMetadata(keyId) at line 1199 is not wrapped in a try-catch, yet the interface declares throws Exception and remote implementations (Hashicorp Vault, Azure Key Vault) perform a network read that can fail. If that read throws, the exception propagates up through signature() (no catch there) or hybridSignature() (rethrown as RuntimeCamelException), which fails the exchange and discards a perfectly good signature.

This directly contradicts the intent expressed in the try-catch around updateKeyMetadata just below (lines 1215-1223): "Failing the exchange over it would throw away a perfectly good signature to protect a counter whose loss merely delays a rotation." The same rationale applies to the read that precedes the write.

The fix is to extend the non-stateful try-catch to cover getKeyMetadata as well — either by moving the null-check + getKeyMetadata call inside the else branch's try block, or by wrapping both calls together.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@oscerd

oscerd commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated #28094 (this branch carries its commits, so merge #28094 first, or this one shows only its own commit once #28094 is on main).

  • Rebase. The 4.24 upgrade guide conflicted; both notes of this stack now sit together before the camel-jbang - camel get endpoint shows the scope of an endpoint section, at a different place from the other open camel-pqc PRs, so they stop conflicting with each other.
  • Open point. The unguarded getKeyMetadata read for a non-stateful key is fixed in CAMEL-25797: camel-pqc - the usage-count and PENDING_ROTATION key rotation signals now fire #28094's commit "CAMEL-25797: Address review feedback - a failed metadata read for a non-stateful key keeps the signature", which this branch includes: the read is treated like the write, logged once per producer with the signature kept, while a stateful key still fails the exchange. Resolving the rebase conflict carried that into this PR's version of recordKeyUsageAfterSign; this PR's own commit is otherwise unchanged.

Verification: camel-pqc on JDK 21 (mvn clean install) and JDK 25 (mvn test: 280 tests, 0 failures); reactor from camel-pqc through the catalog and DSL modules BUILD SUCCESS, no regenerated-file drift.

Claude Code on behalf of oscerd

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, thanks. The open point is fixed. The getKeyMetadata read for a non-stateful key is now caught (that comes from #28094's 71322b5, which this branch carries). The rebase resolved recordKeyUsageAfterSign correctly: the stateful/no-manager warning comes first, then the guarded read, then warnKeyUsageNotRecorded when there is no metadata. The persist still runs before the signature headers are set in signature() and hybridSignature(). I resolved the gnodet-bot thread.

  1. Merge order: please merge #28094 first, after its upgrade-guide fix (two of its paragraphs ended up in the hybridKdfAlgorithm section, see #28094). Then rebase this branch, so it shows only its own commit and does not bring the misplaced paragraphs along. Until then it conflicts with #28089 in the upgrade guide.
  2. Nit, inline: the last paragraph of the upgrade-guide note.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Comment thread docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc Outdated

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Previous findings are all addressed. LGTM.

The getKeyMetadata call is now correctly wrapped in a try-catch with the right stateful/non-stateful split: re-throw for stateful keys (a persist failure on an unrecoverable index must fail the exchange), log-and-return for non-stateful keys (a usage-counter read failure should not discard a valid signature). All other exit paths consistently apply the stateful flag. The usageRecordFailureWarned AtomicBoolean one-time-warning pattern is the correct per-producer design.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@oscerd
oscerd force-pushed the fix/CAMEL-25796-pqc-stateful-index-order branch from a42a589 to af4a55e Compare October 21, 2026 09:08
@oscerd

oscerd commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated #28094 (previous head a42a589 → af4a55e).

Claude Code on behalf of oscerd

oscerd and others added 2 commits October 21, 2026 15:20
…gnature is published

sign() consumes the one-time index inside an LMS, HSS, XMSS or XMSSMT private key. The
signature was placed on the message first and the advanced key persisted afterwards, so a
crash, a kill -9, or any failure inside the persist step left an emitted signature in the
world while the stored key still sat at the old index. On restart the key resumes there
and signs a second time with an index that is already in use, which is precisely what the
one-time property of these schemes forbids.

The order is now reversed in both signature() and hybridSignature(): the key state is
recorded first, and only then are the signature headers set. The window cannot be closed
completely - BouncyCastle advances the index inside sign() and exposes no reserve-then-sign
API - but a failure now fails the exchange instead of releasing a signature whose index
advance was never stored. Losing a signature is recoverable; reusing an index is not.

The three paths that quietly skipped persistence for a stateful key - no keyLifecycleManager,
no CamelPQCKeyId header, no stored metadata - now log a warning naming the reason. It is
emitted once per producer so a high volume route does not flood the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Andrea Cosentino <ancosen@gmail.com>
…ich keys warn without a keyLifecycleManager

Only a stateful key warns when no keyLifecycleManager is configured; the note read as if every key did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Andrea Cosentino <ancosen@gmail.com>
@oscerd
oscerd force-pushed the fix/CAMEL-25796-pqc-stateful-index-order branch from af4a55e to 1389b72 Compare October 21, 2026 13:23
@oscerd

oscerd commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #28094 is merged (previous head af4a55e → 1389b72): the branch carries only its own two commits (the fix and the upgrade-guide wording nit). No conflict; outside the upgrade guide the changed lines are byte-identical to the previous head, and its guide section still follows the #28094 section. camel-pqc module tests on JDK 21: green.

Claude Code on behalf of oscerd

@github-actions github-actions Bot removed the catalog label Oct 21, 2026

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review after the rebase (a42a589 -> 1389b72). Thanks Andrea.

With #28094 on main, the branch now shows only its own two commits. PQCProducer and PQCStatefulKeyPersistOrderTest are byte-identical to the version I approved. The new commit fixes the last paragraph of the upgrade-guide note: it now says that only a stateful key warns without a keyLifecycleManager, which matches the code. The section sits after the #28094 one, so the earlier conflict is gone.

LGTM once CI is green.

This review was generated by an AI agent (Claude Code on behalf of davsclaus) and may contain inaccuracies. Please verify all suggestions before applying.

@davsclaus davsclaus added this to the 4.24.0 milestone Oct 22, 2026
@davsclaus davsclaus added the bug Something isn't working label Oct 22, 2026
@davsclaus
davsclaus merged commit e6885df into apache:main Oct 22, 2026
7 checks passed
@oscerd
oscerd deleted the fix/CAMEL-25796-pqc-stateful-index-order branch October 22, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working components docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants