diff --git a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/HTTPSignatureConstants.java b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/HTTPSignatureConstants.java index af262dd7325..92d125cacfe 100644 --- a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/HTTPSignatureConstants.java +++ b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/HTTPSignatureConstants.java @@ -52,6 +52,20 @@ public final class HTTPSignatureConstants extends RSSecurityConstants { */ public static final String RSSEC_HTTP_SIGNATURE_DIGEST_ALGORITHM = "rs.security.http.signature.digest.algorithm"; + /** + * The maximum age in seconds of a signed "Date" header on an inbound request or response. A message whose + * signed "Date" is older than this is rejected, so that a captured signed message can not be replayed + * after its time to live has passed. The default is 300 seconds, a value of 0 or less disables the check. + */ + public static final String RSSEC_HTTP_SIGNATURE_TIME_TO_LIVE = "rs.security.http.signature.time.to.live"; + + /** + * The number of seconds a signed "Date" header on an inbound request or response may be in the future, + * to allow for clock skew. The default is 60 seconds, a negative value disables the check. + */ + public static final String RSSEC_HTTP_SIGNATURE_FUTURE_TIME_TO_LIVE = + "rs.security.http.signature.future.time.to.live"; + private HTTPSignatureConstants() { // complete diff --git a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/MessageVerifier.java b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/MessageVerifier.java index 0402c81602d..5a0d9c5dd07 100644 --- a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/MessageVerifier.java +++ b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/MessageVerifier.java @@ -18,17 +18,21 @@ */ package org.apache.cxf.rs.security.httpsignature; +import java.text.ParseException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import org.apache.cxf.common.logging.LogUtils; import org.apache.cxf.helpers.CastUtils; +import org.apache.cxf.jaxrs.utils.HttpUtils; import org.apache.cxf.message.Message; import org.apache.cxf.message.MessageUtils; +import org.apache.cxf.rs.security.httpsignature.exception.InvalidSignatureException; import org.apache.cxf.rs.security.httpsignature.exception.MissingSignatureHeaderException; import org.apache.cxf.rs.security.httpsignature.exception.MultipleSignatureHeaderException; import org.apache.cxf.rs.security.httpsignature.provider.AlgorithmProvider; @@ -36,8 +40,18 @@ import org.apache.cxf.rs.security.httpsignature.provider.SecurityProvider; import org.apache.cxf.rs.security.httpsignature.utils.DefaultSignatureConstants; import org.apache.cxf.rs.security.httpsignature.utils.SignatureHeaderUtils; +import org.tomitribe.auth.signatures.Signature; public class MessageVerifier { + /** + * The default maximum age in seconds of a signed "Date" header + */ + public static final int DEFAULT_TIME_TO_LIVE = 300; + /** + * The default number of seconds a signed "Date" header may be in the future + */ + public static final int DEFAULT_FUTURE_TIME_TO_LIVE = 60; + private static final Logger LOG = LogUtils.getL7dLogger(MessageVerifier.class); private AlgorithmProvider algorithmProvider; @@ -46,6 +60,8 @@ public class MessageVerifier { private final SignatureValidator signatureValidator; private final List requiredHeaders; private boolean addDefaultRequiredHeaders = true; + private int timeToLive = DEFAULT_TIME_TO_LIVE; + private int futureTimeToLive = DEFAULT_FUTURE_TIME_TO_LIVE; public MessageVerifier(KeyProvider keyProvider) { this(keyProvider, (List)null); @@ -151,9 +167,48 @@ public void verifyMessage(Map> messageHeaders, String metho method, uri, signedHeaders); + verifyDateHeader(messageHeaders); LOG.fine("Finished signature verification"); } + /** + * Reject a message whose signed "Date" header is outside the configured time window, so that a captured + * signed message can not be replayed once its time to live has passed. A "Date" header which is not + * covered by the signature is ignored, as it can be changed along with the rest of the unsigned message. + */ + private void verifyDateHeader(Map> messageHeaders) { + if (timeToLive <= 0 && futureTimeToLive < 0) { + return; + } + List signedHeaders = Signature.fromString(messageHeaders.get("Signature").get(0)).getHeaders(); + if (!signedHeaders.contains("date")) { + return; + } + String date = null; + for (Map.Entry> entry : messageHeaders.entrySet()) { + if ("date".equalsIgnoreCase(entry.getKey()) && !entry.getValue().isEmpty()) { + date = entry.getValue().get(0); + break; + } + } + if (date == null) { + return; + } + final long dateTime; + try { + dateTime = HttpUtils.getHttpDateFormat().parse(date).getTime(); + } catch (ParseException ex) { + throw new InvalidSignatureException("the signed Date header can not be parsed"); + } + long now = System.currentTimeMillis(); + if (timeToLive > 0 && now - dateTime > TimeUnit.SECONDS.toMillis(timeToLive)) { + throw new InvalidSignatureException("the signed Date header is older than the time to live"); + } + if (futureTimeToLive >= 0 && dateTime - now > TimeUnit.SECONDS.toMillis(futureTimeToLive)) { + throw new InvalidSignatureException("the signed Date header is in the future"); + } + } + private void inspectMultipleSignatureHeaders(Map> responseHeaders) { if (responseHeaders.get("Signature").size() > 1) { throw new MultipleSignatureHeaderException("there are multiple signature headers in request"); @@ -178,4 +233,28 @@ public void setAddDefaultRequiredHeaders(boolean addDefaultRequiredHeaders) { this.addDefaultRequiredHeaders = addDefaultRequiredHeaders; } + public int getTimeToLive() { + return timeToLive; + } + + /** + * Set the maximum age in seconds of a signed "Date" header. A message whose signed "Date" is older + * than this is rejected. The default is 300 seconds, a value of 0 or less disables the check. + */ + public void setTimeToLive(int timeToLive) { + this.timeToLive = timeToLive; + } + + public int getFutureTimeToLive() { + return futureTimeToLive; + } + + /** + * Set the number of seconds a signed "Date" header may be in the future, to allow for clock skew. + * The default is 60 seconds, a negative value disables the check. + */ + public void setFutureTimeToLive(int futureTimeToLive) { + this.futureTimeToLive = futureTimeToLive; + } + } diff --git a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/filters/AbstractSignatureInFilter.java b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/filters/AbstractSignatureInFilter.java index 491eaa9543f..5e7852a7b7c 100644 --- a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/filters/AbstractSignatureInFilter.java +++ b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/filters/AbstractSignatureInFilter.java @@ -153,7 +153,29 @@ protected MessageVerifier createMessageVerifier() { final String finalSignatureAlgorithm = signatureAlgorithm; final Provider provider = Security.getProvider(DefaultSignatureConstants.SECURITY_PROVIDER); - return new MessageVerifier(keyId -> publicKey, keyId -> provider, keyId -> finalSignatureAlgorithm); + MessageVerifier verifier = + new MessageVerifier(keyId -> publicKey, keyId -> provider, keyId -> finalSignatureAlgorithm); + verifier.setTimeToLive(getContextualInteger(m, + HTTPSignatureConstants.RSSEC_HTTP_SIGNATURE_TIME_TO_LIVE, MessageVerifier.DEFAULT_TIME_TO_LIVE)); + verifier.setFutureTimeToLive(getContextualInteger(m, + HTTPSignatureConstants.RSSEC_HTTP_SIGNATURE_FUTURE_TIME_TO_LIVE, + MessageVerifier.DEFAULT_FUTURE_TIME_TO_LIVE)); + return verifier; + } + + private static int getContextualInteger(Message m, String key, int defaultValue) { + Object value = m.getContextualProperty(key); + if (value instanceof Number) { + return ((Number)value).intValue(); + } + if (value instanceof String) { + try { + return Integer.parseInt((String)value); + } catch (NumberFormatException ex) { + LOG.warning("Incorrect integer value of " + value + " specified for: " + key); + } + } + return defaultValue; } protected abstract void handleException(Exception ex); diff --git a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtils.java b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtils.java index e4a6f248961..68c52c6d64b 100644 --- a/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtils.java +++ b/rt/rs/security/http-signature/src/main/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtils.java @@ -21,9 +21,8 @@ import java.net.URI; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.time.Clock; -import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.Collections; @@ -42,8 +41,7 @@ private SignatureHeaderUtils() { } * Add a date header at the current time using the ZoneOffset. Date format is http */ public static void addDateHeader(Map> messageHeaders, ZoneOffset zoneOffset) { - String date = DateTimeFormatter.RFC_1123_DATE_TIME - .format(LocalDateTime.now().atZone(Clock.system(zoneOffset).getZone())); + String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(zoneOffset)); messageHeaders.put("Date", Collections.singletonList(date)); } diff --git a/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/MessageVerifierTest.java b/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/MessageVerifierTest.java index 002000f5caf..118062d7315 100644 --- a/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/MessageVerifierTest.java +++ b/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/MessageVerifierTest.java @@ -23,6 +23,8 @@ import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -285,6 +287,58 @@ public void defaultRequiredHeaderNotPresentTestNotRequired() throws IOException headerVerifier.verifyMessage(headers, METHOD, URI, new MessageImpl(), null); } + @Test(expected = InvalidSignatureException.class) + public void staleSignedDateHeaderFails() throws IOException { + Map> headers = createMockHeaders(); + headers.put("Date", Collections.singletonList(httpDate(-10))); + createAndAddSignature(headers); + messageVerifier.verifyMessage(headers, METHOD, URI, new MessageImpl(), MESSAGE_BODY.getBytes()); + } + + @Test(expected = InvalidSignatureException.class) + public void futureSignedDateHeaderFails() throws IOException { + Map> headers = createMockHeaders(); + headers.put("Date", Collections.singletonList(httpDate(10))); + createAndAddSignature(headers); + messageVerifier.verifyMessage(headers, METHOD, URI, new MessageImpl(), MESSAGE_BODY.getBytes()); + } + + @Test + public void signedDateHeaderWithinTimeToLivePasses() throws IOException { + Map> headers = createMockHeaders(); + headers.put("Date", Collections.singletonList(httpDate(-4))); + createAndAddSignature(headers); + messageVerifier.verifyMessage(headers, METHOD, URI, new MessageImpl(), MESSAGE_BODY.getBytes()); + } + + @Test + public void staleUnsignedDateHeaderIsIgnored() throws IOException { + Map> headers = createMockHeaders(); + headers.put("Date", Collections.singletonList(httpDate(-10))); + new MessageSigner(keyId -> keyPair.getPrivate(), KEY_ID, Collections.singletonList("host")) + .sign(headers, URI, METHOD); + messageVerifier.verifyMessage(headers, METHOD, URI, new MessageImpl(), MESSAGE_BODY.getBytes()); + } + + @Test + public void staleSignedDateHeaderPassesWithoutTimeToLive() throws IOException { + Map> headers = createMockHeaders(); + headers.put("Date", Collections.singletonList(httpDate(-10))); + createAndAddSignature(headers); + + MessageVerifier noTtlVerifier = new MessageVerifier(keyId -> keyPair.getPublic()); + noTtlVerifier.setSecurityProvider(new MockSecurityProvider()); + noTtlVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); + noTtlVerifier.setAddDefaultRequiredHeaders(false); + noTtlVerifier.setTimeToLive(0); + noTtlVerifier.verifyMessage(headers, METHOD, URI, new MessageImpl(), MESSAGE_BODY.getBytes()); + } + + private static String httpDate(int minutesFromNow) { + return DateTimeFormatter.RFC_1123_DATE_TIME.format( + ZonedDateTime.now(ZoneOffset.UTC).plusMinutes(minutesFromNow)); + } + private static void createAndAddSignature(Map> headers) throws IOException { messageSigner.sign(headers, URI, METHOD); } diff --git a/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/SpecExamplesTest.java b/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/SpecExamplesTest.java index 6da1dcb59a4..2989231abf3 100644 --- a/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/SpecExamplesTest.java +++ b/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/SpecExamplesTest.java @@ -34,6 +34,7 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -66,6 +67,7 @@ import org.apache.cxf.rs.security.httpsignature.provider.KeyProvider; import org.apache.cxf.rs.security.httpsignature.provider.MockAlgorithmProvider; import org.apache.cxf.rs.security.httpsignature.provider.MockSecurityProvider; +import org.apache.cxf.rs.security.httpsignature.utils.SignatureHeaderUtils; import org.junit.Before; import org.junit.BeforeClass; @@ -137,10 +139,8 @@ public void defaultTest() throws IOException { // Now check we validate the Date header as expected on an empty header list headers.put("Signature", Collections.singletonList(expectedHeader)); - MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); + MessageVerifier messageVerifier = createSpecExampleVerifier(); messageVerifier.setAddDefaultRequiredHeaders(false); - messageVerifier.setSecurityProvider(new MockSecurityProvider()); - messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); messageVerifier.verifyMessage(headers, "POST", "/foo?param=value&pet=dog", new MessageImpl(), MESSAGE_BODY.getBytes()); } @@ -178,10 +178,8 @@ public void interceptorDefaultTest() { assertEquals(signatureHeader.replaceAll("headers=\"date\",", ""), expectedHeader); // Verify that the request signature can be verified by the filter - MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); + MessageVerifier messageVerifier = createSpecExampleVerifier(); messageVerifier.setAddDefaultRequiredHeaders(false); - messageVerifier.setSecurityProvider(new MockSecurityProvider()); - messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); VerifySignatureFilter verifySignatureFilter = new VerifySignatureFilter(); verifySignatureFilter.setMessageVerifier(messageVerifier); @@ -211,10 +209,8 @@ public void basicTest() throws IOException { assertEquals(signatureHeader, expectedHeader); - MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); + MessageVerifier messageVerifier = createSpecExampleVerifier(); messageVerifier.setAddDefaultRequiredHeaders(false); - messageVerifier.setSecurityProvider(new MockSecurityProvider()); - messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); messageVerifier.verifyMessage(headers, "POST", "/foo?param=value&pet=dog", new MessageImpl(), MESSAGE_BODY.getBytes()); } @@ -255,10 +251,8 @@ public void interceptorBasicTest() { assertEquals(signatureHeader, expectedHeader); // Verify that the request signature can be verified by the filter - MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); + MessageVerifier messageVerifier = createSpecExampleVerifier(); messageVerifier.setAddDefaultRequiredHeaders(false); - messageVerifier.setSecurityProvider(new MockSecurityProvider()); - messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); VerifySignatureFilter verifySignatureFilter = new VerifySignatureFilter(); verifySignatureFilter.setMessageVerifier(messageVerifier); @@ -288,9 +282,7 @@ public void allHeadersTest() throws IOException { assertEquals(signatureHeader, expectedHeader); - MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); - messageVerifier.setSecurityProvider(new MockSecurityProvider()); - messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); + MessageVerifier messageVerifier = createSpecExampleVerifier(); messageVerifier.verifyMessage(headers, "POST", "/foo?param=value&pet=dog", new MessageImpl(), MESSAGE_BODY.getBytes()); } @@ -332,10 +324,8 @@ public void interceptorAllHeadersTest() { assertEquals(signatureHeader, expectedHeader); // Verify that the request signature can be verified by the filter - MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); + MessageVerifier messageVerifier = createSpecExampleVerifier(); messageVerifier.setAddDefaultRequiredHeaders(false); - messageVerifier.setSecurityProvider(new MockSecurityProvider()); - messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); VerifySignatureFilter verifySignatureFilter = new VerifySignatureFilter(); verifySignatureFilter.setMessageVerifier(messageVerifier); @@ -357,7 +347,7 @@ public void filterUnsignedBodyOnDeleteTest() throws IOException { // The client signs a DELETE without a body, so no digest is added or signed Map> headers = new HashMap<>(); headers.put("Host", Collections.singletonList("example.com")); - headers.put("Date", Collections.singletonList("Sun, 05 Jan 2014 21:31:40 GMT")); + SignatureHeaderUtils.addDateHeader(headers, ZoneOffset.UTC); MessageSigner messageSigner = new MessageSigner(keyProvider, "Test", Arrays.asList("(request-target)", "host", "date")); messageSigner.sign(headers, "/foo/1", method); @@ -391,7 +381,7 @@ public void filterUnsignedBodyOnDeleteTest() throws IOException { public void clientFilterUnsignedResponseBodyTest() throws IOException { // The service signs a response without a body, so no digest is added or signed Map> headers = new HashMap<>(); - headers.put("Date", Collections.singletonList("Sun, 05 Jan 2014 21:31:40 GMT")); + SignatureHeaderUtils.addDateHeader(headers, ZoneOffset.UTC); MessageSigner messageSigner = new MessageSigner(keyProvider, "Test"); messageSigner.sign(headers, "", ""); @@ -428,7 +418,7 @@ public void filterUnsignedBodyOnGetTest() throws IOException { // The client signs a GET without a body, so no digest is added or signed Map> headers = new HashMap<>(); headers.put("Host", Collections.singletonList("example.com")); - headers.put("Date", Collections.singletonList("Sun, 05 Jan 2014 21:31:40 GMT")); + SignatureHeaderUtils.addDateHeader(headers, ZoneOffset.UTC); MessageSigner messageSigner = new MessageSigner(keyProvider, "Test", Arrays.asList("(request-target)", "host", "date")); messageSigner.sign(headers, "/foo/1", method); @@ -462,7 +452,7 @@ public void filterUnsignedBodyOnGetTest() throws IOException { public void clientFilterUnsignedErrorResponseBodyTest() throws IOException { // The service signs a response without a body, so no digest is added or signed Map> headers = new HashMap<>(); - headers.put("Date", Collections.singletonList("Sun, 05 Jan 2014 21:31:40 GMT")); + SignatureHeaderUtils.addDateHeader(headers, ZoneOffset.UTC); MessageSigner messageSigner = new MessageSigner(keyProvider, "Test"); messageSigner.sign(headers, "", ""); @@ -500,6 +490,15 @@ private Exception verifyResponse(MultivaluedMap responseHeaders, return result.get(); } + // The spec examples carry a fixed Date from 2014, so the signed Date time to live check is disabled + private static MessageVerifier createSpecExampleVerifier() { + MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); + messageVerifier.setSecurityProvider(new MockSecurityProvider()); + messageVerifier.setAlgorithmProvider(new MockAlgorithmProvider()); + messageVerifier.setTimeToLive(0); + return messageVerifier; + } + private VerifySignatureFilter createVerifySignatureFilter() { MessageVerifier messageVerifier = new MessageVerifier(keyId -> publicKey); messageVerifier.setSecurityProvider(new MockSecurityProvider()); diff --git a/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtilsTest.java b/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtilsTest.java index 73127258d1a..36c40313ef2 100644 --- a/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtilsTest.java +++ b/rt/rs/security/http-signature/src/test/java/org/apache/cxf/rs/security/httpsignature/utils/SignatureHeaderUtilsTest.java @@ -18,9 +18,17 @@ */ package org.apache.cxf.rs.security.httpsignature.utils; +import java.text.ParseException; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cxf.jaxrs.utils.HttpUtils; import org.apache.cxf.rs.security.httpsignature.exception.DigestFailureException; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; public class SignatureHeaderUtilsTest { @@ -59,4 +67,15 @@ public void testCreateDigestHeaderSHA1() { SignatureHeaderUtils.createDigestHeader("xyz", "SHA-1"); } + @org.junit.Test + public void testAddDateHeaderIsCurrentTime() throws ParseException { + // The header must hold the current instant whatever the system time zone and the requested offset + for (ZoneOffset offset : new ZoneOffset[] {ZoneOffset.UTC, ZoneOffset.ofHours(-7), ZoneOffset.ofHours(9)}) { + Map> headers = new HashMap<>(); + SignatureHeaderUtils.addDateHeader(headers, offset); + long date = HttpUtils.getHttpDateFormat().parse(headers.get("Date").get(0)).getTime(); + assertTrue(Math.abs(System.currentTimeMillis() - date) < 60000L); + } + } + }