Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ public class PQCProducer extends DefaultProducer {
private KeyGenerator keyGenerator;
private KeyPair keyPair;

// Keeps the "usage could not be recorded" warning to one line per producer instead of one per exchange
// Each keeps its warning to one line per producer instead of one per exchange
private final AtomicBoolean usageRecordFailureWarned = new AtomicBoolean();
private final AtomicBoolean keyUsageNotRecordedWarned = new AtomicBoolean();

// Set only when this producer created the Signature itself, so it knows how to create another one.
// Left null when the user configured an instance, which then has to be shared and locked instead.
Expand Down Expand Up @@ -582,9 +583,13 @@ private void signature(Exchange exchange)
updateSignatureFromBody(signerForExchange, exchange.getMessage());
signature = signerForExchange.sign();
}
exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);

// sign() has already advanced the one-time index inside a stateful private key. Record that before the
// signature leaves this processor, so a persistence failure fails the exchange instead of emitting a
// signature whose index advance was never stored - on restart that index would be handed out a second time.
recordKeyUsageAfterSign(exchange);

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

private void verification(Exchange exchange)
Expand Down Expand Up @@ -830,15 +835,16 @@ private void hybridSignature(Exchange exchange)
// Parse to get individual signatures for headers
HybridSignature.HybridSignatureComponents components = HybridSignature.parse(hybridSig);

exchange.getMessage().setHeader(PQCConstants.HYBRID_SIGNATURE, hybridSig);
exchange.getMessage().setHeader(PQCConstants.CLASSICAL_SIGNATURE, components.classicalSignature());
exchange.getMessage().setHeader(PQCConstants.PQC_SIGNATURE, components.pqcSignature());

// Recorded before the signatures are published, for the reason given in signature()
try {
recordKeyUsageAfterSign(exchange);
} catch (Exception e) {
throw new RuntimeCamelException("Failed to record key usage after hybrid signing", e);
}

exchange.getMessage().setHeader(PQCConstants.HYBRID_SIGNATURE, hybridSig);
exchange.getMessage().setHeader(PQCConstants.CLASSICAL_SIGNATURE, components.classicalSignature());
exchange.getMessage().setHeader(PQCConstants.PQC_SIGNATURE, components.pqcSignature());
}

private void hybridVerification(Exchange exchange)
Expand Down Expand Up @@ -1224,19 +1230,26 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception {
return;
}

PrivateKey privateKey = keyPair.getPrivate();
boolean stateful = getStatefulKeyRemaining(privateKey) >= 0;

KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
if (klm == null) {
// Only the stateful risk is worth reporting here. A plain signing endpoint with no lifecycle management
// is an ordinary setup, and with no manager there is no maxKeyUsage threshold to miss.
if (stateful) {
warnKeyUsageNotRecorded(true, "no keyLifecycleManager is configured on the endpoint");
}
return;
}

String keyId = exchange.getMessage().getHeader(PQCConstants.KEY_ID, String.class);
if (ObjectHelper.isEmpty(keyId)) {
warnKeyUsageNotRecorded(stateful,
"the exchange carries no " + PQCConstants.KEY_ID + " header identifying the key");
return;
}

PrivateKey privateKey = keyPair.getPrivate();
boolean stateful = getStatefulKeyRemaining(privateKey) >= 0;

KeyMetadata metadata;
try {
metadata = klm.getKeyMetadata(keyId);
Expand All @@ -1249,13 +1262,7 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception {
return;
}
if (metadata == null) {
Comment thread
davsclaus marked this conversation as resolved.
if (stateful) {
LOG.warn(
"No metadata found for stateful key '{}'. The key index has been advanced by the signing operation "
+ "but cannot be persisted — on restart this index advance will be lost, which may lead to key reuse. "
+ "Ensure a KeyLifecycleManager is properly configured and the key is stored with metadata.",
keyId);
}
warnKeyUsageNotRecorded(stateful, "the keyLifecycleManager holds no metadata for key '" + keyId + "'");
return;
}

Expand Down Expand Up @@ -1290,6 +1297,36 @@ private void warnUsageRecordFailure(String keyId, Exception e) {
}
}

/**
* Warns, once per producer, that a signing operation could not have its key usage recorded.
* <p/>
* For a stateful key this is the serious case: the operation has already consumed the one-time index inside the
* private key, so after a restart the key resumes at the old index and signs again with an index already in use,
* which is what the one-time property of LMS, HSS, XMSS and XMSSMT forbids.
* <p/>
* For any other key the consequence is milder: {@code usageCount} does not advance, so a configured
* {@code maxKeyUsage} rotation threshold is never reached. That is only worth saying when a
* {@code keyLifecycleManager} is configured at all - the caller suppresses it otherwise.
*/
private void warnKeyUsageNotRecorded(boolean stateful, String reason) {
if (!keyUsageNotRecordedWarned.compareAndSet(false, true)) {
return;
}
if (stateful) {
LOG.warn("Signed with the stateful key of signature algorithm {} but its advanced index was not persisted,"
+ " because {}. On restart the key resumes at the previous index and may sign twice with the same"
+ " one-time index. Configure a keyLifecycleManager, store the key with metadata, and send the"
+ " {} header on every exchange. This is reported once per producer.",
getConfiguration().getSignatureAlgorithm(), reason, PQCConstants.KEY_ID);
} else {
LOG.warn("Signed with the key of signature algorithm {} but its usage was not recorded, because {}."
+ " The usageCount of the key does not advance, so a configured maxKeyUsage rotation threshold"
+ " will never be reached. Send the {} header on every exchange and store the key with metadata."
+ " This is reported once per producer.",
getConfiguration().getSignatureAlgorithm(), reason, PQCConstants.KEY_ID);
}
}

/**
* Returns the remaining signatures for a stateful private key, or -1 if the key is not stateful.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.camel.component.pqc;

import java.security.KeyPair;
import java.security.Security;

import org.apache.camel.CamelExecutionException;
import org.apache.camel.Exchange;
import org.apache.camel.Produce;
import org.apache.camel.ProducerTemplate;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.pqc.lifecycle.InMemoryKeyLifecycleManager;
import org.apache.camel.component.pqc.lifecycle.KeyMetadata;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Signing consumes the one-time index of a stateful key, so the advanced key has to be persisted before the signature
* is published. If persisting fails, the exchange must fail rather than hand out a signature whose index advance was
* never recorded.
*/
public class PQCStatefulKeyPersistOrderTest extends CamelTestSupport {

private static final String KEY_ID = "lms-key";

private final FailingOnStoreKeyManager keyManager = new FailingOnStoreKeyManager();

@Produce("direct:sign")
protected ProducerTemplate templateSign;

@BeforeEach
void addProviders() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastlePQCProvider());
}
}

@Override
protected RouteBuilder createRouteBuilder() {
return new RouteBuilder() {
@Override
public void configure() {
context.getRegistry().bind("keyManager", keyManager);
from("direct:sign")
.to("pqc:sign?operation=sign&signatureAlgorithm=LMS&keyLifecycleManager=#keyManager");
}
};
}

@Test
void aFailureToPersistTheIndexFailsTheExchange() throws Exception {
keyManager.generateKeyPair("LMS", KEY_ID);
keyManager.failOnStore = true;

CamelExecutionException e = assertThrows(CamelExecutionException.class,
() -> templateSign.sendBodyAndHeader("direct:sign", "Hello", PQCConstants.KEY_ID, KEY_ID));

assertTrue(e.getCause().getMessage().contains("storeKey failed"),
"the persistence failure must surface: " + e.getCause());
}

@Test
void theSignatureIsNotPublishedWhenPersistenceFails() throws Exception {
keyManager.generateKeyPair("LMS", KEY_ID);
keyManager.failOnStore = true;

Exchange out = templateSign.send("direct:sign", e -> {
e.getMessage().setBody("Hello");
e.getMessage().setHeader(PQCConstants.KEY_ID, KEY_ID);
});

assertTrue(out.isFailed(), "the exchange must be marked failed");
assertNull(out.getMessage().getHeader(PQCConstants.SIGNATURE),
"no signature may be published when the advanced index could not be persisted");
}

@Test
void aSuccessfulSignStillPersistsAndPublishes() throws Exception {
keyManager.generateKeyPair("LMS", KEY_ID);
// generateKeyPair stores the key itself; count only what the signing operation does
keyManager.storeCount = 0;
keyManager.failOnStore = false;

Exchange out = templateSign.send("direct:sign", e -> {
e.getMessage().setBody("Hello");
e.getMessage().setHeader(PQCConstants.KEY_ID, KEY_ID);
});

assertNull(out.getException(), "signing must succeed");
assertTrue(out.getMessage().getHeader(PQCConstants.SIGNATURE, byte[].class).length > 0);
assertEquals(1, keyManager.storeCount, "the advanced stateful key must have been stored");
assertEquals(1, keyManager.getKeyMetadata(KEY_ID).getUsageCount());
}

/**
* An InMemoryKeyLifecycleManager whose storeKey can be made to fail, standing in for an unreachable vault.
*/
private static final class FailingOnStoreKeyManager extends InMemoryKeyLifecycleManager {

private boolean failOnStore;
private int storeCount;

@Override
public void storeKey(String keyId, KeyPair keyPair, KeyMetadata metadata) {
if (failOnStore) {
throw new IllegalStateException("storeKey failed");
}
storeCount++;
super.storeKey(keyId, keyPair, metadata);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,25 @@ the signing exchanges, or use `maxKeyAge` or `nextRotationAt`, if the write is n

The key encapsulation operations do not record usage, so `maxKeyUsage` still never triggers for a KEM key.

=== camel-pqc - a failed stateful signature no longer carries the signature headers

Signing with one of the stateful hash-based algorithms (LMS, HSS, XMSS, XMSSMT) consumes a one-time index inside the
private key. That advanced key is now persisted through the `keyLifecycleManager` *before* the signature is placed on
the message, instead of after.

Such an exchange already failed when the key state could not be stored, so what changes is the content of the failed
message: it no longer carries `CamelPQCSignature`, nor the hybrid `CamelPQCHybridSignature`,
`CamelPQCClassicalSignature` and `CamelPQCPqcSignature` headers. A route that caught the failure with
`onException(...).handled(true)`, or routed it to a dead letter channel, could previously read the signature off the
failed message and pass it on; it can no longer do so. That is the point of the change - a signature whose index
advance was never recorded must not escape, because after a restart the key resumes at the previous index and can
sign a second time with an index already used.

Signing a stateful key without a `keyLifecycleManager`, and signing any key without a `CamelPQCKeyId` header or
with no stored metadata for the key, now logs a warning once per producer rather than being silently ignored. A
non-stateful key without a `keyLifecycleManager` stays silent, as there is no `maxKeyUsage` threshold to miss. For a
stateful key the warning names the index-reuse risk; for any other key it names the `usageCount` that will not advance.

=== camel-jbang - camel get endpoint shows the scope of an endpoint

`camel get endpoint` replaces the `REMOTE` column with `SCOPE`, which tells three kinds of endpoint apart:
Expand Down
Loading