oscerd commented on code in PR #27719:
URL: https://github.com/apache/camel/pull/27719#discussion_r4248497409


##########
components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java:
##########
@@ -191,6 +207,16 @@ protected HttpRequest.Builder prepareRequest(URI uri, 
Exchange exchange) {
         return HttpRequest.newBuilder().uri(uri);
     }
 
+    /**
+     * The query parameters of the participant callback URLs of a step, as the 
participant receives them back.
+     */
+    private static Map<String, String> callbackParameters(LRASagaStep step) {
+        Map<String, String> parameters = new HashMap<>(step.getOptions());
+        step.getCompensation().ifPresent(endpoint -> 
parameters.put(URL_COMPENSATION_KEY, endpoint.getEndpointUri()));
+        step.getCompletion().ifPresent(endpoint -> 
parameters.put(URL_COMPLETION_KEY, endpoint.getEndpointUri()));
+        return parameters;
+    }

Review Comment:
   Agreed that a colliding option name should not end in a confusing 403. 
Filtering the reserved names out of `callbackParameters()` would not avoid it, 
though: `LRAUrlBuilder` still writes the option into the callback URL next to 
the parameter with the same name, so the duplicate is still in the URL the 
coordinator calls back, and the signed map would no longer match that URL. Such 
a name already broke the callback before this change (the duplicate also fails 
`parseQuery()` on the unsigned path).
   
   So I went with failing fast: `LRASagaService.registerStep()` now rejects a 
saga option named like `Camel-Saga-Compensate`, `Camel-Saga-Complete` or 
`Camel-Saga-Signature`, in any case (the signed values become headers, whose 
names are case-insensitive), with an `IllegalArgumentException` when the route 
is created. Test: 
`LRASagaServiceAllowlistTest#testRegisterStepRejectsReservedOptionNames`.
   
   _Claude Code on behalf of oscerd_
   



##########
components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java:
##########
@@ -0,0 +1,93 @@
+/*
+ * 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.service.lra;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.Map;
+import java.util.StringJoiner;
+import java.util.TreeMap;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Signs the participant callback URLs enlisted with the LRA coordinator, and 
verifies the signature when the
+ * coordinator calls them back, using HMAC-SHA256 with the callback secret of 
the LRA saga service.
+ * <p>
+ * The signature covers the callback path and every query parameter of the 
URL: the step endpoint URIs and the saga
+ * options. Parameters are sorted and encoded individually, so the signature 
does not depend on the order or the
+ * encoding used by the coordinator when it calls the URL back.
+ */
+final class LRACallbackSigner {
+
+    private static final String ALGORITHM = "HmacSHA256";
+
+    private final SecretKeySpec key;
+
+    LRACallbackSigner(String secret) {
+        this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), 
ALGORITHM);
+    }
+
+    /**
+     * Computes the signature of a callback URL.
+     *
+     * @param  callbackPath the participant path the URL points to (compensate 
or complete)
+     * @param  parameters   the query parameters of the URL, without the 
signature
+     * @return              the signature, Base64 URL-safe encoded without 
padding
+     */
+    String sign(String callbackPath, Map<String, String> parameters) {
+        try {
+            Mac mac = Mac.getInstance(ALGORITHM);
+            mac.init(key);
+            byte[] digest = mac.doFinal(canonicalForm(callbackPath, 
parameters).getBytes(StandardCharsets.UTF_8));
+            return 
Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException("Cannot compute the signature of 
the LRA participant callback URL", e);
+        }
+    }
+
+    /**
+     * Checks the signature of a callback URL, in constant time.
+     *
+     * @param  callbackPath the participant path that was called (compensate 
or complete)
+     * @param  parameters   the query parameters of the called URL, without 
the signature
+     * @param  signature    the signature received with the URL, may be 
<tt>null</tt>
+     * @return              true if the signature matches the path and the 
parameters
+     */
+    boolean verify(String callbackPath, Map<String, String> parameters, String 
signature) {
+        if (signature == null) {
+            return false;
+        }
+        byte[] expected = sign(callbackPath, 
parameters).getBytes(StandardCharsets.UTF_8);
+        return MessageDigest.isEqual(expected, 
signature.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String canonicalForm(String callbackPath, Map<String, 
String> parameters) {
+        StringJoiner joiner = new StringJoiner("&", callbackPath + "?", "");
+        new TreeMap<>(parameters).forEach((name, value) -> 
joiner.add(encode(name) + "=" + encode(value)));
+        return joiner.toString();

Review Comment:
   Done slightly differently: `verify()` now returns `false` when the 
parameters are `null`, so a callback whose query cannot be read is rejected by 
the signer itself instead of relying on the callers' guards. `sign()` is only 
called with the map built in `LRAClient`. Test: 
`LRACallbackSignerTest#missingParametersDoNotMatch`.
   
   _Claude Code on behalf of oscerd_
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to