diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/DefaultMessageSignatureComponentResolver.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/DefaultMessageSignatureComponentResolver.java
new file mode 100644
index 000000000..7e390bda2
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/DefaultMessageSignatureComponentResolver.java
@@ -0,0 +1,497 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.io.ByteArrayOutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpMessage;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.MessageHeaders;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.structured.StructuredFieldBareItem;
+import org.apache.hc.core5.http.structured.StructuredFieldDictionary;
+import org.apache.hc.core5.http.structured.StructuredFieldHeaders;
+import org.apache.hc.core5.http.structured.StructuredFieldInnerList;
+import org.apache.hc.core5.http.structured.StructuredFieldItem;
+import org.apache.hc.core5.http.structured.StructuredFieldList;
+import org.apache.hc.core5.http.structured.StructuredFieldMember;
+import org.apache.hc.core5.http.structured.StructuredFieldSerializer;
+import org.apache.hc.core5.http.structured.StructuredFieldType;
+import org.apache.hc.core5.http.structured.StructuredFieldValue;
+import org.apache.hc.core5.net.URIAuthority;
+
+/**
+ * RFC 9421 resolver for the standard derived components and field component parameters.
+ *
+ * @since 5.5
+ */
+public final class DefaultMessageSignatureComponentResolver implements MessageSignatureComponentResolver {
+
+ /**
+ * Singleton instance.
+ *
+ * @since 5.5
+ */
+ public static final DefaultMessageSignatureComponentResolver INSTANCE =
+ new DefaultMessageSignatureComponentResolver();
+
+ private static final Set FIELD_PARAMETERS = new HashSet<>();
+
+ static {
+ FIELD_PARAMETERS.add("sf");
+ FIELD_PARAMETERS.add("key");
+ FIELD_PARAMETERS.add("bs");
+ FIELD_PARAMETERS.add("req");
+ FIELD_PARAMETERS.add("tr");
+ }
+
+ private DefaultMessageSignatureComponentResolver() {
+ }
+
+ @Override
+ public String resolve(
+ final MessageSignatureComponent component, final MessageSignatureContext context)
+ throws MessageSignatureException {
+ validateKnownParameters(component);
+ if (component.isDerived()) {
+ return resolveDerived(component, context);
+ }
+ return resolveField(component, context);
+ }
+
+ private static void validateKnownParameters(final MessageSignatureComponent component)
+ throws MessageSignatureException {
+ final Set allowed = new HashSet<>();
+ if (component.isDerived()) {
+ allowed.add("req");
+ if ("@query-param".equals(component.getName())) {
+ allowed.add("name");
+ }
+ } else {
+ allowed.addAll(FIELD_PARAMETERS);
+ }
+ for (final Map.Entry entry : component.getParameters()) {
+ if (!allowed.contains(entry.getKey())) {
+ throw new MessageSignatureException("Unknown or inapplicable component parameter: "
+ + entry.getKey() + " on " + component.getName());
+ }
+ }
+ }
+
+ private static String resolveField(
+ final MessageSignatureComponent component, final MessageSignatureContext context)
+ throws MessageSignatureException {
+ final boolean req = booleanParameter(component, "req");
+ final boolean trailer = booleanParameter(component, "tr");
+ final boolean sf = booleanParameter(component, "sf");
+ final boolean bs = booleanParameter(component, "bs");
+ final StructuredFieldBareItem keyParameter = component.getParameters().get("key");
+ final String key = stringParameter(keyParameter, "key", false);
+
+ if (bs && (sf || key != null)) {
+ throw new MessageSignatureException("bs is incompatible with sf and key");
+ }
+
+ final MessageHeaders headers = selectHeaders(context, req, trailer);
+ final String fieldName = component.getName();
+ if (!headers.containsHeader(fieldName)) {
+ throw new MessageSignatureException("Covered HTTP field is missing: " + fieldName);
+ }
+
+ try {
+ if (bs) {
+ return binaryWrapped(headers, fieldName);
+ }
+ if (key != null) {
+ final StructuredFieldDictionary dictionary = StructuredFieldHeaders.parseDictionary(headers, fieldName);
+ final StructuredFieldMember member = dictionary.get(key);
+ if (member == null) {
+ throw new MessageSignatureException("Structured Field dictionary key is missing: " + key);
+ }
+ return serializeMember(member);
+ }
+ if (sf) {
+ final StructuredFieldValueType type = context.getStructuredFieldType(fieldName);
+ if (type == null) {
+ throw new MessageSignatureException("Structured Field type is unknown for: " + fieldName);
+ }
+ final StructuredFieldValue value;
+ switch (type) {
+ case ITEM:
+ value = StructuredFieldHeaders.parseItem(headers, fieldName);
+ break;
+ case LIST:
+ value = StructuredFieldHeaders.parseList(headers, fieldName);
+ break;
+ case DICTIONARY:
+ value = StructuredFieldHeaders.parseDictionary(headers, fieldName);
+ break;
+ default:
+ throw new MessageSignatureException("Unsupported Structured Field type: " + type);
+ }
+ final String serialized = StructuredFieldSerializer.serialize(value);
+ return serialized != null ? serialized : "";
+ }
+ return combineFieldValues(headers, fieldName);
+ } catch (final ParseException ex) {
+ throw new MessageSignatureException("Invalid covered HTTP field: " + fieldName, ex);
+ }
+ }
+
+ private static MessageHeaders selectHeaders(
+ final MessageSignatureContext context, final boolean req, final boolean trailer)
+ throws MessageSignatureException {
+ if (req) {
+ if (context.getTarget() instanceof HttpRequest) {
+ throw new MessageSignatureException("req MUST NOT be used when the target message is a request");
+ }
+ final HttpRequest relatedRequest = context.getRelatedRequest();
+ if (relatedRequest == null) {
+ throw new MessageSignatureException("req requires the related request");
+ }
+ if (trailer) {
+ final MessageHeaders trailers = context.getRelatedRequestTrailers();
+ if (trailers == null) {
+ throw new MessageSignatureException("Requested related-request trailers are unavailable");
+ }
+ return trailers;
+ }
+ return relatedRequest;
+ }
+ if (trailer) {
+ final MessageHeaders trailers = context.getTrailers();
+ if (trailers == null) {
+ throw new MessageSignatureException("Requested trailers are unavailable");
+ }
+ return trailers;
+ }
+ return context.getTarget();
+ }
+
+ private static String combineFieldValues(final MessageHeaders headers, final String name) {
+ final StringBuilder buffer = new StringBuilder();
+ final Iterator iterator = headers.headerIterator(name);
+ while (iterator.hasNext()) {
+ if (buffer.length() > 0) {
+ buffer.append(", ");
+ }
+ buffer.append(trimOws(unfold(iterator.next().getValue())));
+ }
+ return buffer.toString();
+ }
+
+ private static String binaryWrapped(final MessageHeaders headers, final String name)
+ throws MessageSignatureException {
+ final List members = new ArrayList<>();
+ final Iterator iterator = headers.headerIterator(name);
+ while (iterator.hasNext()) {
+ final String value = trimOws(unfold(iterator.next().getValue()));
+ members.add(StructuredFieldItem.ofByteSequence(toFieldOctets(value)));
+ }
+ return StructuredFieldSerializer.serializeList(StructuredFieldList.of(members));
+ }
+
+ private static byte[] toFieldOctets(final String value) throws MessageSignatureException {
+ final byte[] bytes = new byte[value.length()];
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (ch > 0xff) {
+ throw new MessageSignatureException(
+ "HTTP field value cannot be represented as raw octets for ;bs");
+ }
+ bytes[i] = (byte) ch;
+ }
+ return bytes;
+ }
+
+ private static String serializeMember(final StructuredFieldMember member) {
+ if (member instanceof StructuredFieldItem) {
+ return StructuredFieldSerializer.serializeItem((StructuredFieldItem) member);
+ }
+ return StructuredFieldSerializer.serializeInnerList((StructuredFieldInnerList) member);
+ }
+
+ private static String resolveDerived(
+ final MessageSignatureComponent component, final MessageSignatureContext context)
+ throws MessageSignatureException {
+ final boolean req = booleanParameter(component, "req");
+ final HttpMessage target = context.getTarget();
+ if (req && target instanceof HttpRequest) {
+ throw new MessageSignatureException("req MUST NOT be used when the target message is a request");
+ }
+ final HttpMessage selected = req ? context.getRelatedRequest() : target;
+ if (selected == null) {
+ throw new MessageSignatureException("req requires the related request");
+ }
+
+ final String name = component.getName();
+ if ("@status".equals(name)) {
+ if (!(selected instanceof HttpResponse)) {
+ throw new MessageSignatureException("@status requires a response target");
+ }
+ final int code = ((HttpResponse) selected).getCode();
+ if (code < 100 || code > 599) {
+ throw new MessageSignatureException("Invalid HTTP status code: " + code);
+ }
+ return Integer.toString(code);
+ }
+
+ if (!(selected instanceof HttpRequest)) {
+ throw new MessageSignatureException(name + " requires a request target or ;req");
+ }
+ final HttpRequest request = (HttpRequest) selected;
+ switch (name) {
+ case "@method":
+ return request.getMethod();
+ case "@target-uri":
+ return targetUri(request);
+ case "@authority":
+ return authority(request);
+ case "@scheme":
+ return scheme(request);
+ case "@request-target":
+ return requestTarget(request);
+ case "@path":
+ return path(request);
+ case "@query":
+ return query(request);
+ case "@query-param":
+ return queryParameter(request, component);
+ default:
+ throw new MessageSignatureException("Unknown derived component: " + name);
+ }
+ }
+
+ private static String scheme(final HttpRequest request) throws MessageSignatureException {
+ final String scheme = request.getScheme();
+ if (scheme == null || scheme.isEmpty()) {
+ throw new MessageSignatureException("Request scheme is unavailable");
+ }
+ return scheme.toLowerCase(Locale.ROOT);
+ }
+
+ private static String authority(final HttpRequest request) throws MessageSignatureException {
+ final URIAuthority authority = request.getAuthority();
+ if (authority == null) {
+ throw new MessageSignatureException("Request authority is unavailable");
+ }
+ final String host = authority.getHostName().toLowerCase(Locale.ROOT);
+ final int port = authority.getPort();
+ final boolean defaultPort = port == 80 && "http".equalsIgnoreCase(request.getScheme())
+ || port == 443 && "https".equalsIgnoreCase(request.getScheme());
+ final String formattedHost = host.indexOf(':') >= 0 && !host.startsWith("[") ? "[" + host + "]" : host;
+ return port >= 0 && !defaultPort ? formattedHost + ':' + port : formattedHost;
+ }
+
+ private static String targetUri(final HttpRequest request) throws MessageSignatureException {
+ final String scheme = scheme(request);
+ final String authority = authority(request);
+ try {
+ final URI uri = request.getUri();
+ final String rawPath = uri.getRawPath();
+ final String path = rawPath == null || rawPath.isEmpty() ? "/" : rawPath;
+ final String rawQuery = uri.getRawQuery();
+ return scheme + "://" + authority + path + (rawQuery != null ? "?" + rawQuery : "");
+ } catch (final URISyntaxException ex) {
+ throw new MessageSignatureException("Invalid request target URI", ex);
+ }
+ }
+
+ private static String requestTarget(final HttpRequest request) throws MessageSignatureException {
+ final String requestUri = request.getRequestUri();
+ if (requestUri == null || requestUri.isEmpty()) {
+ throw new MessageSignatureException("Request target is unavailable");
+ }
+ return requestUri;
+ }
+
+ private static URI uri(final HttpRequest request) throws MessageSignatureException {
+ try {
+ return request.getUri();
+ } catch (final URISyntaxException ex) {
+ throw new MessageSignatureException("Invalid request URI", ex);
+ }
+ }
+
+ private static String path(final HttpRequest request) throws MessageSignatureException {
+ final String rawPath = uri(request).getRawPath();
+ return rawPath == null || rawPath.isEmpty() ? "/" : rawPath;
+ }
+
+ private static String query(final HttpRequest request) throws MessageSignatureException {
+ final String rawQuery = uri(request).getRawQuery();
+ return rawQuery != null ? "?" + rawQuery : "?";
+ }
+
+ private static String queryParameter(
+ final HttpRequest request, final MessageSignatureComponent component)
+ throws MessageSignatureException {
+ final String encodedName = stringParameter(component.getParameters().get("name"), "name", true);
+ final String rawQuery = uri(request).getRawQuery();
+ if (rawQuery == null) {
+ throw new MessageSignatureException("Named query parameter is missing: " + encodedName);
+ }
+ String found = null;
+ int matches = 0;
+ final String[] pairs = rawQuery.split("&", -1);
+ for (final String pair : pairs) {
+ // WHATWG application/x-www-form-urlencoded parsing skips empty byte sequences.
+ if (pair.isEmpty()) {
+ continue;
+ }
+ final int equals = pair.indexOf('=');
+ final String rawName = equals >= 0 ? pair.substring(0, equals) : pair;
+ final String rawValue = equals >= 0 ? pair.substring(equals + 1) : "";
+ final String canonicalName = formEncode(formDecode(rawName));
+ if (encodedName.equals(canonicalName)) {
+ matches++;
+ found = formEncode(formDecode(rawValue));
+ }
+ }
+ if (matches == 0) {
+ throw new MessageSignatureException("Named query parameter is missing: " + encodedName);
+ }
+ if (matches > 1) {
+ throw new MessageSignatureException("Named query parameter occurs more than once: " + encodedName);
+ }
+ return found;
+ }
+
+ private static boolean booleanParameter(
+ final MessageSignatureComponent component, final String name) throws MessageSignatureException {
+ final StructuredFieldBareItem item = component.getParameters().get(name);
+ if (item == null) {
+ return false;
+ }
+ if (item.getType() != StructuredFieldType.BOOLEAN) {
+ throw new MessageSignatureException("Component parameter '" + name + "' must be Boolean");
+ }
+ return item.getBooleanValue();
+ }
+
+ private static String stringParameter(
+ final StructuredFieldBareItem item, final String name, final boolean required)
+ throws MessageSignatureException {
+ if (item == null) {
+ if (required) {
+ throw new MessageSignatureException("Missing required component parameter: " + name);
+ }
+ return null;
+ }
+ if (item.getType() != StructuredFieldType.STRING) {
+ throw new MessageSignatureException("Component parameter '" + name + "' must be String");
+ }
+ return item.getTextValue();
+ }
+
+ private static String unfold(final String value) {
+ if (value == null || value.indexOf('\r') < 0) {
+ return value != null ? value : "";
+ }
+ return value.replaceAll("\\r\\n[ \\t]+", " ");
+ }
+
+ private static String trimOws(final String value) {
+ int begin = 0;
+ int end = value.length();
+ while (begin < end && (value.charAt(begin) == ' ' || value.charAt(begin) == '\t')) {
+ begin++;
+ }
+ while (end > begin && (value.charAt(end - 1) == ' ' || value.charAt(end - 1) == '\t')) {
+ end--;
+ }
+ return value.substring(begin, end);
+ }
+
+ private static String formDecode(final String input) {
+ final ByteArrayOutputStream bytes = new ByteArrayOutputStream(input.length());
+ for (int i = 0; i < input.length(); ) {
+ final char ch = input.charAt(i);
+ if (ch == '+') {
+ bytes.write(' ');
+ i++;
+ } else if (ch == '%' && i + 2 < input.length()
+ && isHex(input.charAt(i + 1)) && isHex(input.charAt(i + 2))) {
+ bytes.write((hex(input.charAt(i + 1)) << 4) | hex(input.charAt(i + 2)));
+ i += 3;
+ } else {
+ final int codePoint = input.codePointAt(i);
+ final byte[] encoded = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
+ bytes.write(encoded, 0, encoded.length);
+ i += Character.charCount(codePoint);
+ }
+ }
+ return new String(bytes.toByteArray(), StandardCharsets.UTF_8);
+ }
+
+ private static String formEncode(final String value) {
+ final byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+ final StringBuilder buffer = new StringBuilder(bytes.length);
+ for (final byte b : bytes) {
+ final int octet = b & 0xff;
+ if (octet >= 'a' && octet <= 'z'
+ || octet >= 'A' && octet <= 'Z'
+ || octet >= '0' && octet <= '9'
+ || octet == '*' || octet == '-' || octet == '.' || octet == '_') {
+ buffer.append((char) octet);
+ } else {
+ buffer.append('%');
+ final char high = Character.toUpperCase(Character.forDigit((octet >>> 4) & 0xf, 16));
+ final char low = Character.toUpperCase(Character.forDigit(octet & 0xf, 16));
+ buffer.append(high).append(low);
+ }
+ }
+ return buffer.toString();
+ }
+
+ private static boolean isHex(final char ch) {
+ return ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F';
+ }
+
+ private static int hex(final char ch) {
+ if (ch >= '0' && ch <= '9') {
+ return ch - '0';
+ }
+ if (ch >= 'a' && ch <= 'f') {
+ return ch - 'a' + 10;
+ }
+ return ch - 'A' + 10;
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignature.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignature.java
new file mode 100644
index 000000000..512d3a188
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignature.java
@@ -0,0 +1,106 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.structured.StructuredFieldItem;
+import org.apache.hc.core5.http.structured.StructuredFieldSerializer;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.LangUtils;
+
+/**
+ * One labeled Byte Sequence value from the RFC 9421 {@code Signature} field.
+ *
+ * @since 5.5
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+public final class MessageSignature {
+
+ private final String label;
+ private final byte[] value;
+
+ /**
+ * Creates a message signature from the given label and Byte Sequence value.
+ *
+ * @param label the signature label.
+ * @param value the raw signature bytes.
+ * @since 5.5
+ */
+ public MessageSignature(final String label, final byte[] value) {
+ this.label = MessageSignatureSupport.validateLabel(label);
+ this.value = Args.notNull(value, "Signature value").clone();
+ }
+
+ /**
+ * Returns the signature label.
+ *
+ * @since 5.5
+ */
+ public String getLabel() {
+ return label;
+ }
+
+ /**
+ * Returns a copy of the raw signature bytes.
+ *
+ * @since 5.5
+ */
+ public byte[] getValue() {
+ return value.clone();
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj instanceof MessageSignature) {
+ final MessageSignature that = (MessageSignature) obj;
+ return Objects.equals(this.label, that.label)
+ && Arrays.equals(this.value, that.value);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = LangUtils.HASH_SEED;
+ hash = LangUtils.hashCode(hash, this.label);
+ hash = LangUtils.hashCode(hash, Arrays.hashCode(this.value));
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return label + "=" + StructuredFieldSerializer.serializeItem(StructuredFieldItem.ofByteSequence(value));
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureBaseBuilder.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureBaseBuilder.java
new file mode 100644
index 000000000..f2bb95d04
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureBaseBuilder.java
@@ -0,0 +1,125 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.hc.core5.http.structured.StructuredFieldSerializer;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Builds the deterministic RFC 9421 signature base. No cryptography is performed.
+ *
+ * @since 5.5
+ */
+public final class MessageSignatureBaseBuilder {
+
+ private final MessageSignatureComponentResolver resolver;
+
+ /**
+ * Creates a builder backed by the default component resolver.
+ *
+ * @since 5.5
+ */
+ public MessageSignatureBaseBuilder() {
+ this(DefaultMessageSignatureComponentResolver.INSTANCE);
+ }
+
+ /**
+ * Creates a builder backed by the given component resolver.
+ *
+ * @param resolver the resolver used to derive covered component values.
+ * @since 5.5
+ */
+ public MessageSignatureBaseBuilder(final MessageSignatureComponentResolver resolver) {
+ this.resolver = Args.notNull(resolver, "Component resolver");
+ }
+
+ /**
+ * Builds the signature base for the given input and context.
+ *
+ * @param input the covered components and signature parameters.
+ * @param context the message context from which component values are resolved.
+ * @return the signature base string.
+ * @throws MessageSignatureException if a component is duplicated, cannot be resolved, or yields
+ * an invalid or non-ASCII value.
+ * @since 5.5
+ */
+ public String build(final MessageSignatureInput input, final MessageSignatureContext context)
+ throws MessageSignatureException {
+ Args.notNull(input, "Signature input");
+ Args.notNull(context, "Signature context");
+ final StringBuilder buffer = new StringBuilder(256);
+ final Set seen = new HashSet<>();
+ for (final MessageSignatureComponent component : input.getComponents()) {
+ if (!seen.add(component)) {
+ throw new MessageSignatureException("Duplicate covered component: " + component.serialize());
+ }
+ final String value = resolver.resolve(component, context);
+ validateComponentValue(component, value);
+ buffer.append(component.serialize()).append(": ").append(value).append('\n');
+ }
+ buffer.append("\"@signature-params\": ")
+ .append(StructuredFieldSerializer.serializeInnerList(input.toInnerList()));
+ ensureAscii(buffer);
+ return buffer.toString();
+ }
+
+ private static void validateComponentValue(
+ final MessageSignatureComponent component, final String value) throws MessageSignatureException {
+ if (value == null) {
+ throw new MessageSignatureException("Resolver returned null for " + component.serialize());
+ }
+ if (component.isDerived() && !value.isEmpty()
+ && (value.charAt(0) == ' ' || value.charAt(value.length() - 1) == ' ')) {
+ throw new MessageSignatureException(
+ "Derived component value must not start or end with whitespace: " + component.serialize());
+ }
+ for (int i = 0; i < value.length(); i++) {
+ final char ch = value.charAt(i);
+ if (component.isDerived()) {
+ if (ch < 0x20 || ch > 0x7e) {
+ throw new MessageSignatureException(
+ "Invalid derived component value for " + component.serialize() + " at index " + i);
+ }
+ } else if (ch != '\t' && (ch < 0x20 || ch > 0x7e)) {
+ throw new MessageSignatureException(
+ "Invalid HTTP field component value for " + component.serialize() + " at index " + i);
+ }
+ }
+ }
+
+ private static void ensureAscii(final CharSequence value) throws MessageSignatureException {
+ for (int i = 0; i < value.length(); i++) {
+ if (value.charAt(i) > 0x7f) {
+ throw new MessageSignatureException("Signature base contains non-ASCII data at index " + i);
+ }
+ }
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponent.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponent.java
new file mode 100644
index 000000000..4299d6114
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponent.java
@@ -0,0 +1,203 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.structured.StructuredFieldBareItem;
+import org.apache.hc.core5.http.structured.StructuredFieldItem;
+import org.apache.hc.core5.http.structured.StructuredFieldParameters;
+import org.apache.hc.core5.http.structured.StructuredFieldSerializer;
+import org.apache.hc.core5.http.structured.StructuredFieldType;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.LangUtils;
+
+/**
+ * Immutable RFC covered component identifier.
+ *
+ * @since 5.5
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+public final class MessageSignatureComponent {
+
+ private final String name;
+ private final StructuredFieldParameters parameters;
+
+ private MessageSignatureComponent(final String name, final StructuredFieldParameters parameters) {
+ this.name = validateName(name);
+ this.parameters = Args.notNull(parameters, "Component parameters");
+ }
+
+ /**
+ * Creates a component identifier from an already canonical component name.
+ *
+ * @since 5.5
+ */
+ public static MessageSignatureComponent create(
+ final String name, final StructuredFieldParameters parameters) {
+ return new MessageSignatureComponent(name, parameters);
+ }
+
+ /**
+ * Creates a derived component identifier without parameters.
+ *
+ * @since 5.5
+ */
+ public static MessageSignatureComponent derived(final String name) {
+ Args.check(name != null && name.startsWith("@"), "Derived component name must start with '@'");
+ return new MessageSignatureComponent(name, StructuredFieldParameters.EMPTY);
+ }
+
+ /**
+ * Creates a lower-case HTTP field component identifier without parameters.
+ *
+ * @since 5.5
+ */
+ public static MessageSignatureComponent field(final String name) {
+ Args.notBlank(name, "Field name");
+ return new MessageSignatureComponent(name.toLowerCase(Locale.ROOT), StructuredFieldParameters.EMPTY);
+ }
+
+ /**
+ * Creates an RFC 9421 {@code @query-param} identifier.
+ *
+ * @since 5.5
+ */
+ public static MessageSignatureComponent queryParam(final String encodedName) {
+ final StructuredFieldParameters parameters = StructuredFieldParameters.builder()
+ .put("name", StructuredFieldBareItem.ofString(encodedName))
+ .build();
+ return new MessageSignatureComponent("@query-param", parameters);
+ }
+
+ static MessageSignatureComponent fromStructuredFieldItem(final StructuredFieldItem item) throws ParseException {
+ final StructuredFieldBareItem bareItem = item.getBareItem();
+ if (bareItem.getType() != StructuredFieldType.STRING) {
+ throw new ParseException("RFC 9421 component identifier must be a Structured Field String");
+ }
+ final String name = bareItem.getTextValue();
+ try {
+ return new MessageSignatureComponent(name, item.getParameters());
+ } catch (final IllegalArgumentException ex) {
+ throw new ParseException(ex.getMessage());
+ }
+ }
+
+ private static String validateName(final String name) {
+ Args.notBlank(name, "Component name");
+ Args.check(!"@signature-params".equals(name), "@signature-params must not be a covered component");
+ if (!name.startsWith("@")) {
+ Args.check(name.equals(name.toLowerCase(Locale.ROOT)),
+ "HTTP field component names must be lowercase: %s", name);
+ for (int i = 0; i < name.length(); i++) {
+ Args.check(isTokenChar(name.charAt(i)), "Invalid HTTP field component name: %s", name);
+ }
+ }
+ return name;
+ }
+
+ private static boolean isTokenChar(final char ch) {
+ return ch >= 'a' && ch <= 'z'
+ || ch >= '0' && ch <= '9'
+ || ch == '!' || ch == '#' || ch == '$' || ch == '%' || ch == '&' || ch == '\''
+ || ch == '*' || ch == '+' || ch == '-' || ch == '.' || ch == '^' || ch == '_'
+ || ch == '`' || ch == '|' || ch == '~';
+ }
+
+ /**
+ * Returns the canonical component name.
+ *
+ * @since 5.5
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Returns the component parameters.
+ *
+ * @since 5.5
+ */
+ public StructuredFieldParameters getParameters() {
+ return parameters;
+ }
+
+ /**
+ * Tests whether this is a derived component.
+ *
+ * @since 5.5
+ */
+ public boolean isDerived() {
+ return name.startsWith("@");
+ }
+
+ StructuredFieldItem toStructuredFieldItem() {
+ return StructuredFieldItem.of(StructuredFieldBareItem.ofString(name), parameters);
+ }
+
+ /**
+ * Returns the strict Structured Fields serialization used in the signature base.
+ *
+ * @since 5.5
+ */
+ public String serialize() {
+ return StructuredFieldSerializer.serializeItem(toStructuredFieldItem());
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj instanceof MessageSignatureComponent) {
+ final MessageSignatureComponent that = (MessageSignatureComponent) obj;
+ // RFC 9421 requires parameter order to be preserved for serialization, but
+ // explicitly declares it insignificant when comparing component identifiers.
+ return Objects.equals(name, that.name)
+ && Objects.equals(parameters.asMap(), that.parameters.asMap());
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = LangUtils.HASH_SEED;
+ hash = LangUtils.hashCode(hash, name);
+ hash = LangUtils.hashCode(hash, parameters.asMap());
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return serialize();
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponentResolver.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponentResolver.java
new file mode 100644
index 000000000..1026d7894
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponentResolver.java
@@ -0,0 +1,48 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+/**
+ * Resolves a covered component to its RFC canonical component value.
+ *
+ * @since 5.5
+ */
+public interface MessageSignatureComponentResolver {
+
+ /**
+ * Resolves the given covered component to its canonical value in the supplied context.
+ *
+ * @param component the covered component to resolve.
+ * @param context the message signature context.
+ * @return the canonical component value.
+ * @throws MessageSignatureException if the component cannot be resolved.
+ * @since 5.5
+ */
+ String resolve(final MessageSignatureComponent component, final MessageSignatureContext context)
+ throws MessageSignatureException;
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureContext.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureContext.java
new file mode 100644
index 000000000..1ad628a3f
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureContext.java
@@ -0,0 +1,192 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.HttpMessage;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.MessageHeaders;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Message context needed to resolve RFC 9421 components. Its thread-safety depends on
+ * the supplied HTTP message and trailer instances.
+ * This object intentionally contains no key lookup, cryptography, or signature policy.
+ *
+ * @since 5.5
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE_CONDITIONAL)
+public final class MessageSignatureContext {
+
+ private final HttpMessage target;
+ private final HttpRequest relatedRequest;
+ private final MessageHeaders trailers;
+ private final MessageHeaders relatedRequestTrailers;
+ private final Map structuredFieldTypes;
+
+ private MessageSignatureContext(final Builder builder) {
+ this.target = Args.notNull(builder.target, "Target message");
+ this.relatedRequest = builder.relatedRequest;
+ this.trailers = builder.trailers;
+ this.relatedRequestTrailers = builder.relatedRequestTrailers;
+ this.structuredFieldTypes = Collections.unmodifiableMap(new LinkedHashMap<>(builder.structuredFieldTypes));
+ }
+
+ /**
+ * Creates a builder for a context targeting the given message.
+ *
+ * @param target the message to be signed or verified.
+ * @since 5.5
+ */
+ public static Builder builder(final HttpMessage target) {
+ return new Builder(target);
+ }
+
+ /**
+ * Returns the target message to be signed or verified.
+ *
+ * @since 5.5
+ */
+ public HttpMessage getTarget() {
+ return target;
+ }
+
+ /**
+ * Returns the related request bound to this response, or {@code null} if none.
+ *
+ * @since 5.5
+ */
+ public HttpRequest getRelatedRequest() {
+ return relatedRequest;
+ }
+
+ /**
+ * Returns the trailers of the target message, or {@code null} if none.
+ *
+ * @since 5.5
+ */
+ public MessageHeaders getTrailers() {
+ return trailers;
+ }
+
+ /**
+ * Returns the trailers of the related request, or {@code null} if none.
+ *
+ * @since 5.5
+ */
+ public MessageHeaders getRelatedRequestTrailers() {
+ return relatedRequestTrailers;
+ }
+
+ /**
+ * Returns the structured field type registered for the given field name, or {@code null} if none.
+ *
+ * @param fieldName the field name, matched case-insensitively.
+ * @since 5.5
+ */
+ public StructuredFieldValueType getStructuredFieldType(final String fieldName) {
+ return structuredFieldTypes.get(fieldName.toLowerCase(Locale.ROOT));
+ }
+
+ /**
+ * Builder of {@link MessageSignatureContext} instances.
+ *
+ * @since 5.5
+ */
+ public static final class Builder {
+ private final HttpMessage target;
+ private HttpRequest relatedRequest;
+ private MessageHeaders trailers;
+ private MessageHeaders relatedRequestTrailers;
+ private final Map structuredFieldTypes = new LinkedHashMap<>();
+
+ private Builder(final HttpMessage target) {
+ this.target = Args.notNull(target, "Target message");
+ }
+
+ /**
+ * Sets the related request bound to a response target.
+ *
+ * @since 5.5
+ */
+ public Builder relatedRequest(final HttpRequest relatedRequest) {
+ this.relatedRequest = relatedRequest;
+ return this;
+ }
+
+ /**
+ * Sets the trailers of the target message.
+ *
+ * @since 5.5
+ */
+ public Builder trailers(final MessageHeaders trailers) {
+ this.trailers = trailers;
+ return this;
+ }
+
+ /**
+ * Sets the trailers of the related request.
+ *
+ * @since 5.5
+ */
+ public Builder relatedRequestTrailers(final MessageHeaders trailers) {
+ this.relatedRequestTrailers = trailers;
+ return this;
+ }
+
+ /**
+ * Registers the structured field type of a header field.
+ *
+ * @param fieldName the field name, stored case-insensitively.
+ * @param valueType the structured field value type.
+ * @since 5.5
+ */
+ public Builder structuredField(
+ final String fieldName, final StructuredFieldValueType valueType) {
+ structuredFieldTypes.put(
+ Args.notBlank(fieldName, "Field name").toLowerCase(Locale.ROOT),
+ Args.notNull(valueType, "Structured Field type"));
+ return this;
+ }
+
+ /**
+ * Builds a context from the current builder state.
+ *
+ * @since 5.5
+ */
+ public MessageSignatureContext build() {
+ return new MessageSignatureContext(this);
+ }
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureException.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureException.java
new file mode 100644
index 000000000..44bb51ad0
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureException.java
@@ -0,0 +1,58 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import org.apache.hc.core5.http.ProtocolException;
+
+/**
+ * Signals an RFC 9421 message signature representation or canonicalization error.
+ *
+ * @since 5.5
+ */
+public class MessageSignatureException extends ProtocolException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a new exception with the given detail message.
+ *
+ * @since 5.5
+ */
+ public MessageSignatureException(final String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a new exception with the given detail message and cause.
+ *
+ * @since 5.5
+ */
+ public MessageSignatureException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureFields.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureFields.java
new file mode 100644
index 000000000..7e653d0ea
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureFields.java
@@ -0,0 +1,197 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.MessageHeaders;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.structured.StructuredFieldDictionary;
+import org.apache.hc.core5.http.structured.StructuredFieldHeaders;
+import org.apache.hc.core5.http.structured.StructuredFieldInnerList;
+import org.apache.hc.core5.http.structured.StructuredFieldItem;
+import org.apache.hc.core5.http.structured.StructuredFieldMember;
+import org.apache.hc.core5.http.structured.StructuredFieldType;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * RFC 9421 parsing and serialization of {@code Signature-Input} and {@code Signature}.
+ *
+ * @since 5.5
+ */
+public final class MessageSignatureFields {
+
+ /**
+ * The {@code Signature-Input} header name.
+ *
+ * @since 5.5
+ */
+ public static final String SIGNATURE_INPUT = "Signature-Input";
+
+ /**
+ * The {@code Signature} header name.
+ *
+ * @since 5.5
+ */
+ public static final String SIGNATURE = "Signature";
+
+ private MessageSignatureFields() {
+ }
+
+ /**
+ * Parses the {@code Signature-Input} field into its labeled inputs, in wire order.
+ *
+ * @param headers the message headers.
+ * @return the parsed signature inputs.
+ * @throws ParseException if the field is malformed or a label is repeated.
+ * @since 5.5
+ */
+ public static List parseSignatureInput(final MessageHeaders headers) throws ParseException {
+ Args.notNull(headers, "Message headers");
+ final List result = new ArrayList<>();
+ final Set labels = new HashSet<>();
+ for (final Map.Entry entry
+ : StructuredFieldHeaders.parseDictionaryEntries(headers, SIGNATURE_INPUT)) {
+ if (!labels.add(entry.getKey())) {
+ throw new ParseException("Duplicate Signature-Input label: " + entry.getKey());
+ }
+ if (!(entry.getValue() instanceof StructuredFieldInnerList)) {
+ throw new ParseException("Signature-Input member must be an Inner List: " + entry.getKey());
+ }
+ result.add(MessageSignatureInput.fromInnerList(
+ entry.getKey(), (StructuredFieldInnerList) entry.getValue()));
+ }
+ return result;
+ }
+
+ /**
+ * Parses the {@code Signature} field into its labeled signatures, in wire order.
+ *
+ * @param headers the message headers.
+ * @return the parsed signatures.
+ * @throws ParseException if the field is malformed or a label is repeated.
+ * @since 5.5
+ */
+ public static List parseSignature(final MessageHeaders headers) throws ParseException {
+ Args.notNull(headers, "Message headers");
+ final List result = new ArrayList<>();
+ final Set labels = new HashSet<>();
+ for (final Map.Entry entry
+ : StructuredFieldHeaders.parseDictionaryEntries(headers, SIGNATURE)) {
+ if (!labels.add(entry.getKey())) {
+ throw new ParseException("Duplicate Signature label: " + entry.getKey());
+ }
+ if (!(entry.getValue() instanceof StructuredFieldItem)) {
+ throw new ParseException("Signature member must be a Byte Sequence Item: " + entry.getKey());
+ }
+ final StructuredFieldItem item = (StructuredFieldItem) entry.getValue();
+ if (item.getBareItem().getType() != StructuredFieldType.BYTE_SEQUENCE
+ || !item.getParameters().isEmpty()) {
+ throw new ParseException("Signature member must be an unparameterized Byte Sequence: "
+ + entry.getKey());
+ }
+ result.add(new MessageSignature(entry.getKey(), item.getBareItem().getByteSequenceValue()));
+ }
+ return result;
+ }
+
+ /**
+ * Serializes the signature inputs into a {@code Signature-Input} header.
+ *
+ * @param inputs the signature inputs.
+ * @return the formatted header.
+ * @since 5.5
+ */
+ public static Header formatSignatureInput(final List inputs) {
+ final List source = Args.notNull(inputs, "Signature inputs");
+ final StructuredFieldDictionary.Builder builder = StructuredFieldDictionary.builder();
+ final Set labels = new HashSet<>();
+ for (final MessageSignatureInput input : source) {
+ final MessageSignatureInput checked = Args.notNull(input, "Signature input");
+ Args.check(labels.add(checked.getLabel()), "Duplicate Signature-Input label: %s", checked.getLabel());
+ builder.put(checked.getLabel(), checked.toInnerList());
+ }
+ return StructuredFieldHeaders.format(SIGNATURE_INPUT, builder.build());
+ }
+
+ /**
+ * Serializes the signatures into a {@code Signature} header.
+ *
+ * @param signatures the signatures.
+ * @return the formatted header.
+ * @since 5.5
+ */
+ public static Header formatSignature(final List signatures) {
+ final List source = Args.notNull(signatures, "Signatures");
+ final StructuredFieldDictionary.Builder builder = StructuredFieldDictionary.builder();
+ final Set labels = new HashSet<>();
+ for (final MessageSignature signature : source) {
+ final MessageSignature checked = Args.notNull(signature, "Signature");
+ Args.check(labels.add(checked.getLabel()), "Duplicate Signature label: %s", checked.getLabel());
+ builder.put(checked.getLabel(), StructuredFieldItem.ofByteSequence(checked.getValue()));
+ }
+ return StructuredFieldHeaders.format(SIGNATURE, builder.build());
+ }
+
+ /**
+ * Verifies the RFC 9421 requirement that Signature-Input and Signature carry the same labels.
+ *
+ * @param inputs the parsed signature inputs.
+ * @param signatures the parsed signatures.
+ * @throws MessageSignatureException if either side repeats a label or the label sets differ.
+ * @since 5.5
+ */
+ public static void validateMatchingLabels(
+ final List inputs, final List signatures)
+ throws MessageSignatureException {
+ final List inputList = Args.notNull(inputs, "Signature inputs");
+ final List signatureList = Args.notNull(signatures, "Signatures");
+ final Set inputLabels = new HashSet<>();
+ for (final MessageSignatureInput input : inputList) {
+ final MessageSignatureInput checked = Args.notNull(input, "Signature input");
+ if (!inputLabels.add(checked.getLabel())) {
+ throw new MessageSignatureException("Duplicate Signature-Input label: " + checked.getLabel());
+ }
+ }
+ final Set signatureLabels = new HashSet<>();
+ for (final MessageSignature signature : signatureList) {
+ final MessageSignature checked = Args.notNull(signature, "Signature");
+ if (!signatureLabels.add(checked.getLabel())) {
+ throw new MessageSignatureException("Duplicate Signature label: " + checked.getLabel());
+ }
+ }
+ if (!inputLabels.equals(signatureLabels)) {
+ throw new MessageSignatureException("Signature-Input and Signature labels do not match");
+ }
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureInput.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureInput.java
new file mode 100644
index 000000000..38f9c2635
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureInput.java
@@ -0,0 +1,186 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.structured.StructuredFieldBareItem;
+import org.apache.hc.core5.http.structured.StructuredFieldInnerList;
+import org.apache.hc.core5.http.structured.StructuredFieldItem;
+import org.apache.hc.core5.http.structured.StructuredFieldParameters;
+import org.apache.hc.core5.http.structured.StructuredFieldSerializer;
+import org.apache.hc.core5.http.structured.StructuredFieldType;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.LangUtils;
+
+/**
+ * One labeled value from the RFC 9421 {@code Signature-Input} dictionary.
+ *
+ * @since 5.5
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+public final class MessageSignatureInput {
+
+ private final String label;
+ private final List components;
+ private final StructuredFieldParameters parameters;
+
+ /**
+ * Creates an entry from a label, its covered components and the signature parameters.
+ *
+ * @param label the signature label.
+ * @param components the covered message components in signing order.
+ * @param parameters the signature parameters.
+ * @since 5.5
+ */
+ public MessageSignatureInput(
+ final String label,
+ final List components,
+ final StructuredFieldParameters parameters) {
+ this.label = MessageSignatureSupport.validateLabel(label);
+ final List source = Args.notNull(components, "Covered components");
+ final List copy = new ArrayList<>(source.size());
+ final Set unique = new HashSet<>();
+ for (final MessageSignatureComponent component : source) {
+ final MessageSignatureComponent checked = Args.notNull(component, "Covered component");
+ Args.check(unique.add(checked), "Duplicate covered component: %s", checked.serialize());
+ copy.add(checked);
+ }
+ this.components = Collections.unmodifiableList(copy);
+ this.parameters = Args.notNull(parameters, "Signature parameters");
+ validateSignatureParameters(parameters);
+ }
+
+ static MessageSignatureInput fromInnerList(
+ final String label, final StructuredFieldInnerList innerList) throws ParseException {
+ final List components = new ArrayList<>(innerList.size());
+ final Set unique = new HashSet<>();
+ for (final StructuredFieldItem item : innerList) {
+ final MessageSignatureComponent component = MessageSignatureComponent.fromStructuredFieldItem(item);
+ if (!unique.add(component)) {
+ throw new ParseException("Duplicate covered component: " + component.serialize());
+ }
+ components.add(component);
+ }
+ try {
+ validateSignatureParameters(innerList.getParameters());
+ return new MessageSignatureInput(label, components, innerList.getParameters());
+ } catch (final IllegalArgumentException ex) {
+ throw new ParseException(ex.getMessage());
+ }
+ }
+
+ private static void validateSignatureParameters(final StructuredFieldParameters parameters) {
+ requireType(parameters, "created", StructuredFieldType.INTEGER);
+ requireType(parameters, "expires", StructuredFieldType.INTEGER);
+ requireType(parameters, "nonce", StructuredFieldType.STRING);
+ requireType(parameters, "alg", StructuredFieldType.STRING);
+ requireType(parameters, "keyid", StructuredFieldType.STRING);
+ requireType(parameters, "tag", StructuredFieldType.STRING);
+ }
+
+ private static void requireType(
+ final StructuredFieldParameters parameters,
+ final String name,
+ final StructuredFieldType expected) {
+ final StructuredFieldBareItem item = parameters.get(name);
+ Args.check(item == null || item.getType() == expected,
+ "Signature parameter '%s' must be %s", name, expected);
+ }
+
+ /**
+ * Returns the signature label.
+ *
+ * @since 5.5
+ */
+ public String getLabel() {
+ return label;
+ }
+
+ /**
+ * Returns the covered message components.
+ *
+ * @since 5.5
+ */
+ public List getComponents() {
+ return components;
+ }
+
+ /**
+ * Returns the signature parameters.
+ *
+ * @since 5.5
+ */
+ public StructuredFieldParameters getParameters() {
+ return parameters;
+ }
+
+ StructuredFieldInnerList toInnerList() {
+ final List items = new ArrayList<>(components.size());
+ for (final MessageSignatureComponent component : components) {
+ items.add(component.toStructuredFieldItem());
+ }
+ return StructuredFieldInnerList.of(items, parameters);
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj instanceof MessageSignatureInput) {
+ final MessageSignatureInput that = (MessageSignatureInput) obj;
+ return Objects.equals(this.label, that.label)
+ && Objects.equals(this.components, that.components)
+ && Objects.equals(this.parameters, that.parameters);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = LangUtils.HASH_SEED;
+ hash = LangUtils.hashCode(hash, this.label);
+ hash = LangUtils.hashCode(hash, this.components);
+ hash = LangUtils.hashCode(hash, this.parameters);
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return label + "=" + StructuredFieldSerializer.serializeInnerList(toInnerList());
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureSupport.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureSupport.java
new file mode 100644
index 000000000..f82d6612a
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureSupport.java
@@ -0,0 +1,44 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import org.apache.hc.core5.http.structured.StructuredFieldDictionary;
+import org.apache.hc.core5.http.structured.StructuredFieldItem;
+import org.apache.hc.core5.util.Args;
+
+final class MessageSignatureSupport {
+
+ private MessageSignatureSupport() {
+ }
+
+ static String validateLabel(final String label) {
+ final String value = Args.notBlank(label, "Signature label");
+ StructuredFieldDictionary.builder().put(value, StructuredFieldItem.ofBoolean(true));
+ return value;
+ }
+
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/StructuredFieldValueType.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/StructuredFieldValueType.java
new file mode 100644
index 000000000..1229da46a
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/StructuredFieldValueType.java
@@ -0,0 +1,56 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+/**
+ * Top-level RFC 9651 Structured Field value type, required by RFC 9421 {@code ;sf}.
+ *
+ * @since 5.5
+ */
+public enum StructuredFieldValueType {
+
+ /**
+ * A single bare item with optional parameters.
+ *
+ * @since 5.5
+ */
+ ITEM,
+
+ /**
+ * An ordered list of members.
+ *
+ * @since 5.5
+ */
+ LIST,
+
+ /**
+ * An ordered map of keys to members.
+ *
+ * @since 5.5
+ */
+ DICTIONARY
+}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/signature/package-info.java b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/package-info.java
new file mode 100644
index 000000000..4aff5cfd1
--- /dev/null
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/signature/package-info.java
@@ -0,0 +1,31 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+/**
+ * RFC HTTP Message Signature field representation and deterministic signature-base canonicalization.
+ * Cryptographic signing, verification, key lookup, and application policy are intentionally out of scope.
+ */
+package org.apache.hc.core5.http.signature;
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/structured/StructuredFieldHeaders.java b/httpcore5/src/main/java/org/apache/hc/core5/http/structured/StructuredFieldHeaders.java
index 205a3eae9..48a61287e 100644
--- a/httpcore5/src/main/java/org/apache/hc/core5/http/structured/StructuredFieldHeaders.java
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/structured/StructuredFieldHeaders.java
@@ -24,10 +24,11 @@
* .
*
*/
-
package org.apache.hc.core5.http.structured;
+import java.util.AbstractMap;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -127,6 +128,29 @@ public static StructuredFieldDictionary parseDictionary(final Header header) thr
return MessageSupport.parseHeaderValueStrict(header, StructuredFieldParser::parseDictionary);
}
+ /**
+ * Parses the matching field lines as individual Structured Field Dictionary entries, preserving
+ * their wire order and repeated keys.
+ *
+ * @param headers the message headers.
+ * @param name the case-insensitive field name.
+ * @return the parsed entries in wire order, including repeated keys.
+ * @throws ParseException if any field line is invalid.
+ */
+ public static List> parseDictionaryEntries(
+ final MessageHeaders headers, final String name) throws ParseException {
+ Args.notNull(headers, "Message headers");
+ Args.notBlank(name, "Header name");
+ final List> entries = new ArrayList<>();
+ MessageSupport.parseElementListStrict(headers, name, (buffer, cursor) -> {
+ final Map entry = new LinkedHashMap<>(1);
+ StructuredFieldParser.parseDictionaryElement(buffer, cursor, entry);
+ final Map.Entry parsed = entry.entrySet().iterator().next();
+ entries.add(new AbstractMap.SimpleImmutableEntry<>(parsed.getKey(), parsed.getValue()));
+ });
+ return Collections.unmodifiableList(entries);
+ }
+
/**
* Parses the matching field lines as a Structured Field Dictionary, reading each field line in
* place and merging its members, without combining the values into a new buffer. A repeated key
@@ -139,11 +163,10 @@ public static StructuredFieldDictionary parseDictionary(final Header header) thr
*/
public static StructuredFieldDictionary parseDictionary(final MessageHeaders headers, final String name)
throws ParseException {
- Args.notNull(headers, "Message headers");
- Args.notBlank(name, "Header name");
final Map members = new LinkedHashMap<>();
- MessageSupport.parseElementListStrict(headers, name, (buffer, cursor) ->
- StructuredFieldParser.parseDictionaryElement(buffer, cursor, members));
+ for (final Map.Entry entry : parseDictionaryEntries(headers, name)) {
+ members.put(entry.getKey(), entry.getValue());
+ }
return StructuredFieldDictionary.copyOf(members);
}
@@ -167,4 +190,5 @@ public static Header format(final String name, final StructuredFieldValue value)
StructuredFieldSerializer.serialize(buffer, value);
return BufferedHeader.create(buffer);
}
+
}
diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/examples/MessageSignatureCanonicalizationExample.java b/httpcore5/src/test/java/org/apache/hc/core5/http/examples/MessageSignatureCanonicalizationExample.java
new file mode 100644
index 000000000..4531021bb
--- /dev/null
+++ b/httpcore5/src/test/java/org/apache/hc/core5/http/examples/MessageSignatureCanonicalizationExample.java
@@ -0,0 +1,81 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.examples;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.signature.MessageSignatureBaseBuilder;
+import org.apache.hc.core5.http.signature.MessageSignatureComponent;
+import org.apache.hc.core5.http.signature.MessageSignatureContext;
+import org.apache.hc.core5.http.signature.MessageSignatureFields;
+import org.apache.hc.core5.http.signature.MessageSignatureInput;
+import org.apache.hc.core5.http.structured.StructuredFieldBareItem;
+import org.apache.hc.core5.http.structured.StructuredFieldParameters;
+
+/**
+ * Builds RFC 9421 Signature-Input and the exact signature base from the RFC Appendix B request.
+ * Deliberately stops before cryptography or key lookup.
+ */
+public final class MessageSignatureCanonicalizationExample {
+
+ private MessageSignatureCanonicalizationExample() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest(
+ "POST", URI.create("https://example.com/foo?param=Value&Pet=dog"));
+ request.addHeader("Content-Digest",
+ "sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:");
+
+ final StructuredFieldParameters queryParameter = StructuredFieldParameters.builder()
+ .put("name", StructuredFieldBareItem.ofString("Pet"))
+ .build();
+ final StructuredFieldParameters signatureParameters = StructuredFieldParameters.builder()
+ .put("created", StructuredFieldBareItem.ofInteger(1618884473L))
+ .put("keyid", StructuredFieldBareItem.ofString("test-key-rsa-pss"))
+ .put("tag", StructuredFieldBareItem.ofString("header-example"))
+ .build();
+
+ final MessageSignatureInput input = new MessageSignatureInput("sig-b22", Arrays.asList(
+ MessageSignatureComponent.derived("@authority"),
+ MessageSignatureComponent.field("content-digest"),
+ MessageSignatureComponent.create("@query-param", queryParameter)), signatureParameters);
+
+ final Header signatureInput = MessageSignatureFields.formatSignatureInput(Collections.singletonList(input));
+ final String signatureBase = new MessageSignatureBaseBuilder().build(
+ input, MessageSignatureContext.builder(request).build());
+
+ System.out.println(signatureInput);
+ System.out.println();
+ System.out.println(signatureBase);
+ }
+
+}
diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureBaseBuilder.java b/httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureBaseBuilder.java
new file mode 100644
index 000000000..57702e4e0
--- /dev/null
+++ b/httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureBaseBuilder.java
@@ -0,0 +1,443 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.net.URI;
+import java.util.Arrays;
+
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.message.HeaderGroup;
+import org.apache.hc.core5.http.structured.StructuredFieldBareItem;
+import org.apache.hc.core5.http.structured.StructuredFieldParameters;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestMessageSignatureBaseBuilder {
+
+ private static final String CONTENT_DIGEST =
+ "sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:";
+
+ private final MessageSignatureBaseBuilder builder = new MessageSignatureBaseBuilder();
+
+ @Test
+ void testRfc9421B22SelectiveCoverage() throws Exception {
+ final BasicHttpRequest request = testRequest();
+ final StructuredFieldParameters queryName = StructuredFieldParameters.builder()
+ .put("name", StructuredFieldBareItem.ofString("Pet"))
+ .build();
+ final StructuredFieldParameters signatureParameters = StructuredFieldParameters.builder()
+ .put("created", StructuredFieldBareItem.ofInteger(1618884473L))
+ .put("keyid", StructuredFieldBareItem.ofString("test-key-rsa-pss"))
+ .put("tag", StructuredFieldBareItem.ofString("header-example"))
+ .build();
+ final MessageSignatureInput input = new MessageSignatureInput("sig-b22", Arrays.asList(
+ MessageSignatureComponent.derived("@authority"),
+ MessageSignatureComponent.field("content-digest"),
+ MessageSignatureComponent.create("@query-param", queryName)), signatureParameters);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request).build());
+
+ final String expected =
+ "\"@authority\": example.com\n"
+ + "\"content-digest\": " + CONTENT_DIGEST + "\n"
+ + "\"@query-param\";name=\"Pet\": dog\n"
+ + "\"@signature-params\": (\"@authority\" \"content-digest\" \"@query-param\";name=\"Pet\")"
+ + ";created=1618884473;keyid=\"test-key-rsa-pss\";tag=\"header-example\"";
+ Assertions.assertEquals(expected, actual);
+ }
+
+ @Test
+ void testRfc9421B23FullCoverage() throws Exception {
+ final BasicHttpRequest request = testRequest();
+ final StructuredFieldParameters signatureParameters = StructuredFieldParameters.builder()
+ .put("created", StructuredFieldBareItem.ofInteger(1618884473L))
+ .put("keyid", StructuredFieldBareItem.ofString("test-key-rsa-pss"))
+ .build();
+ final MessageSignatureInput input = new MessageSignatureInput("sig-b23", Arrays.asList(
+ MessageSignatureComponent.field("date"),
+ MessageSignatureComponent.derived("@method"),
+ MessageSignatureComponent.derived("@path"),
+ MessageSignatureComponent.derived("@query"),
+ MessageSignatureComponent.derived("@authority"),
+ MessageSignatureComponent.field("content-type"),
+ MessageSignatureComponent.field("content-digest"),
+ MessageSignatureComponent.field("content-length")), signatureParameters);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request).build());
+
+ final String expected =
+ "\"date\": Tue, 20 Apr 2021 02:07:55 GMT\n"
+ + "\"@method\": POST\n"
+ + "\"@path\": /foo\n"
+ + "\"@query\": ?param=Value&Pet=dog\n"
+ + "\"@authority\": example.com\n"
+ + "\"content-type\": application/json\n"
+ + "\"content-digest\": " + CONTENT_DIGEST + "\n"
+ + "\"content-length\": 18\n"
+ + "\"@signature-params\": (\"date\" \"@method\" \"@path\" \"@query\" \"@authority\" "
+ + "\"content-type\" \"content-digest\" \"content-length\")"
+ + ";created=1618884473;keyid=\"test-key-rsa-pss\"";
+ Assertions.assertEquals(expected, actual);
+ }
+
+ @Test
+ void testAllStandardRequestDerivedComponents() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest(
+ "POST", URI.create("HTTPS://EXAMPLE.COM:443/path%2Fsegment?x=1"));
+ final MessageSignatureInput input = new MessageSignatureInput("derived", Arrays.asList(
+ MessageSignatureComponent.derived("@method"),
+ MessageSignatureComponent.derived("@target-uri"),
+ MessageSignatureComponent.derived("@authority"),
+ MessageSignatureComponent.derived("@scheme"),
+ MessageSignatureComponent.derived("@request-target"),
+ MessageSignatureComponent.derived("@path"),
+ MessageSignatureComponent.derived("@query")), StructuredFieldParameters.EMPTY);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request).build());
+ final String expected =
+ "\"@method\": POST\n"
+ + "\"@target-uri\": https://example.com/path%2Fsegment?x=1\n"
+ + "\"@authority\": example.com\n"
+ + "\"@scheme\": https\n"
+ + "\"@request-target\": /path%2Fsegment?x=1\n"
+ + "\"@path\": /path%2Fsegment\n"
+ + "\"@query\": ?x=1\n"
+ + "\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"@scheme\" "
+ + "\"@request-target\" \"@path\" \"@query\")";
+ Assertions.assertEquals(expected, actual);
+ }
+
+ @Test
+ void testRfc9421QueryParameterEncodingExample() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create(
+ "https://www.example.com/parameters?var=this%20is%20a%20big%0Amultiline%20value"
+ + "&bar=with+plus+whitespace&fa%C3%A7ade%22%3A%20=something"));
+ final MessageSignatureInput input = new MessageSignatureInput("query", Arrays.asList(
+ MessageSignatureComponent.queryParam("var"),
+ MessageSignatureComponent.queryParam("bar"),
+ MessageSignatureComponent.queryParam("fa%C3%A7ade%22%3A%20")), StructuredFieldParameters.EMPTY);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request).build());
+ final String expected =
+ "\"@query-param\";name=\"var\": this%20is%20a%20big%0Amultiline%20value\n"
+ + "\"@query-param\";name=\"bar\": with%20plus%20whitespace\n"
+ + "\"@query-param\";name=\"fa%C3%A7ade%22%3A%20\": something\n"
+ + "\"@signature-params\": (\"@query-param\";name=\"var\" "
+ + "\"@query-param\";name=\"bar\" \"@query-param\";name=\"fa%C3%A7ade%22%3A%20\")";
+ Assertions.assertEquals(expected, actual);
+ }
+
+
+ @Test
+ void testRfc9421B24ResponseCoverage() throws Exception {
+ final BasicHttpResponse response = new BasicHttpResponse(200);
+ response.addHeader("Content-Type", "application/json");
+ response.addHeader("Content-Digest",
+ "sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:");
+ response.addHeader("Content-Length", "23");
+ final StructuredFieldParameters signatureParameters = StructuredFieldParameters.builder()
+ .put("created", StructuredFieldBareItem.ofInteger(1618884473L))
+ .put("keyid", StructuredFieldBareItem.ofString("test-key-ecc-p256"))
+ .build();
+ final MessageSignatureInput input = new MessageSignatureInput("sig-b24", Arrays.asList(
+ MessageSignatureComponent.derived("@status"),
+ MessageSignatureComponent.field("content-type"),
+ MessageSignatureComponent.field("content-digest"),
+ MessageSignatureComponent.field("content-length")), signatureParameters);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(response).build());
+ final String expected =
+ "\"@status\": 200\n"
+ + "\"content-type\": application/json\n"
+ + "\"content-digest\": sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41Q"
+ + "JgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:\n"
+ + "\"content-length\": 23\n"
+ + "\"@signature-params\": (\"@status\" \"content-type\" \"content-digest\" "
+ + "\"content-length\");created=1618884473;keyid=\"test-key-ecc-p256\"";
+ Assertions.assertEquals(expected, actual);
+ }
+
+ @Test
+ void testInvalidHttpStatusCodeFails() throws Exception {
+ final BasicHttpResponse response = new BasicHttpResponse(600);
+ final MessageSignatureInput input = new MessageSignatureInput("bad-status", Arrays.asList(
+ MessageSignatureComponent.derived("@status")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(response).build()));
+ }
+
+ @Test
+ void testGenericFieldCanonicalizationCombinesValues() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/"));
+ request.addHeader("X-Test", " alpha ");
+ request.addHeader("X-Test", " gamma\tvalue ");
+ final MessageSignatureInput input = new MessageSignatureInput("field", Arrays.asList(
+ MessageSignatureComponent.field("x-test")), StructuredFieldParameters.EMPTY);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request).build());
+ Assertions.assertEquals(
+ "\"x-test\": alpha, gamma\tvalue\n"
+ + "\"@signature-params\": (\"x-test\")", actual);
+ }
+
+ @Test
+ void testQueryWithoutQueryStringIsQuestionMark() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/path"));
+ final MessageSignatureInput input = new MessageSignatureInput("q", Arrays.asList(
+ MessageSignatureComponent.derived("@query")), StructuredFieldParameters.EMPTY);
+ Assertions.assertEquals(
+ "\"@query\": ?\n\"@signature-params\": (\"@query\")",
+ builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testQueryParameterSkipsEmptyFormSegments() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest(
+ "GET", URI.create("https://example.com/?&&a=1&&"));
+ final MessageSignatureInput input = new MessageSignatureInput("q", Arrays.asList(
+ MessageSignatureComponent.queryParam("a")), StructuredFieldParameters.EMPTY);
+ Assertions.assertEquals(
+ "\"@query-param\";name=\"a\": 1\n"
+ + "\"@signature-params\": (\"@query-param\";name=\"a\")",
+ builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testQueryParameterNameMustUseCanonicalEncoding() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/?a=1"));
+ final MessageSignatureInput input = new MessageSignatureInput("q", Arrays.asList(
+ MessageSignatureComponent.queryParam("%61")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testEmptyQueryHasNoEmptyNamedParameter() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/?"));
+ final MessageSignatureInput input = new MessageSignatureInput("q", Arrays.asList(
+ MessageSignatureComponent.queryParam("")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testExplicitEmptyNamedParameterIsAddressable() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/?=value"));
+ final MessageSignatureInput input = new MessageSignatureInput("q", Arrays.asList(
+ MessageSignatureComponent.queryParam("")), StructuredFieldParameters.EMPTY);
+ Assertions.assertEquals(
+ "\"@query-param\";name=\"\": value\n"
+ + "\"@signature-params\": (\"@query-param\";name=\"\")",
+ builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testStatusOnRequestFails() throws Exception {
+ final BasicHttpRequest request = testRequest();
+ final MessageSignatureInput input = new MessageSignatureInput("bad", Arrays.asList(
+ MessageSignatureComponent.derived("@status")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testTrailerFieldIsTakenFromTrailersNotHeaders() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("POST", URI.create("https://example.com/"));
+ request.addHeader("Trailer-Field", "from-header");
+ final HeaderGroup trailers = new HeaderGroup();
+ trailers.addHeader(new BasicHeader("Trailer-Field", "from-trailer"));
+ final StructuredFieldParameters tr = StructuredFieldParameters.builder().putBoolean("tr", true).build();
+ final MessageSignatureInput input = new MessageSignatureInput("tr", Arrays.asList(
+ MessageSignatureComponent.create("trailer-field", tr)), StructuredFieldParameters.EMPTY);
+ final String actual = builder.build(input,
+ MessageSignatureContext.builder(request).trailers(trailers).build());
+ Assertions.assertEquals(
+ "\"trailer-field\";tr: from-trailer\n"
+ + "\"@signature-params\": (\"trailer-field\";tr)", actual);
+ }
+
+ @Test
+ void testDerivedComponentValueMustBePrintableAscii() throws Exception {
+ final MessageSignatureBaseBuilder badBuilder = new MessageSignatureBaseBuilder((component, context) -> "a\tb");
+ final BasicHttpRequest request = testRequest();
+ final MessageSignatureInput input = new MessageSignatureInput("bad", Arrays.asList(
+ MessageSignatureComponent.derived("@method")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> badBuilder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testDerivedComponentValueMustNotHaveEdgeWhitespace() throws Exception {
+ final MessageSignatureBaseBuilder badBuilder = new MessageSignatureBaseBuilder(
+ (component, context) -> " value ");
+ final MessageSignatureInput input = new MessageSignatureInput("bad", Arrays.asList(
+ MessageSignatureComponent.derived("@method")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> badBuilder.build(input, MessageSignatureContext.builder(testRequest()).build()));
+ }
+
+ @Test
+ void testInvalidFieldNameRejectedProgrammatically() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> MessageSignatureComponent.create("bad field", StructuredFieldParameters.EMPTY));
+ }
+
+ @Test
+ void testComponentParameterOrderIsIgnoredForDuplicateDetection() {
+ final StructuredFieldParameters first = StructuredFieldParameters.builder()
+ .putBoolean("sf", true)
+ .putBoolean("req", true)
+ .build();
+ final StructuredFieldParameters second = StructuredFieldParameters.builder()
+ .putBoolean("req", true)
+ .putBoolean("sf", true)
+ .build();
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MessageSignatureInput("dup", Arrays.asList(
+ MessageSignatureComponent.create("example", first),
+ MessageSignatureComponent.create("example", second)),
+ StructuredFieldParameters.EMPTY));
+ }
+
+ @Test
+ void testRepeatedNamedQueryParameterFails() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/?a=1&a=2"));
+ final MessageSignatureInput input = new MessageSignatureInput(
+ "q", Arrays.asList(MessageSignatureComponent.queryParam("a")), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testResponseStatusAndRelatedRequestComponents() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("POST", URI.create("https://example.com/foo?x=1"));
+ final BasicHttpResponse response = new BasicHttpResponse(503);
+ final StructuredFieldParameters req = StructuredFieldParameters.builder().putBoolean("req", true).build();
+ final MessageSignatureInput input = new MessageSignatureInput("res", Arrays.asList(
+ MessageSignatureComponent.derived("@status"),
+ MessageSignatureComponent.create("@authority", req),
+ MessageSignatureComponent.create("@method", req),
+ MessageSignatureComponent.create("@path", req)), StructuredFieldParameters.EMPTY);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(response)
+ .relatedRequest(request)
+ .build());
+ final String expected =
+ "\"@status\": 503\n"
+ + "\"@authority\";req: example.com\n"
+ + "\"@method\";req: POST\n"
+ + "\"@path\";req: /foo\n"
+ + "\"@signature-params\": (\"@status\" \"@authority\";req \"@method\";req \"@path\";req)";
+ Assertions.assertEquals(expected, actual);
+ }
+
+ @Test
+ void testReqOnRequestFails() throws Exception {
+ final BasicHttpRequest request = testRequest();
+ final StructuredFieldParameters req = StructuredFieldParameters.builder().putBoolean("req", true).build();
+ final MessageSignatureInput input = new MessageSignatureInput("bad", Arrays.asList(
+ MessageSignatureComponent.create("@method", req)), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testStructuredFieldStrictSerializationAndDictionaryKey() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/"));
+ request.addHeader("Example-Dict", "a=1, b=2;x=1;y=2, c=(a b c)");
+ final StructuredFieldParameters sf = StructuredFieldParameters.builder().putBoolean("sf", true).build();
+ final StructuredFieldParameters key = StructuredFieldParameters.builder()
+ .put("key", StructuredFieldBareItem.ofString("c"))
+ .build();
+ final MessageSignatureInput input = new MessageSignatureInput("sf", Arrays.asList(
+ MessageSignatureComponent.create("example-dict", sf),
+ MessageSignatureComponent.create("example-dict", key)), StructuredFieldParameters.EMPTY);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request)
+ .structuredField("example-dict", StructuredFieldValueType.DICTIONARY)
+ .build());
+ final String expected =
+ "\"example-dict\";sf: a=1, b=2;x=1;y=2, c=(a b c)\n"
+ + "\"example-dict\";key=\"c\": (a b c)\n"
+ + "\"@signature-params\": (\"example-dict\";sf \"example-dict\";key=\"c\")";
+ Assertions.assertEquals(expected, actual);
+ }
+
+ @Test
+ void testBinaryWrappedFieldValues() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/"));
+ request.addHeader("Set-Cookie", "a=1");
+ request.addHeader("Set-Cookie", "b=2");
+ final StructuredFieldParameters bs = StructuredFieldParameters.builder().putBoolean("bs", true).build();
+ final MessageSignatureInput input = new MessageSignatureInput("bs", Arrays.asList(
+ MessageSignatureComponent.create("set-cookie", bs)), StructuredFieldParameters.EMPTY);
+
+ final String actual = builder.build(input, MessageSignatureContext.builder(request).build());
+ Assertions.assertEquals(
+ "\"set-cookie\";bs: :YT0x:, :Yj0y:\n"
+ + "\"@signature-params\": (\"set-cookie\";bs)", actual);
+ }
+
+ @Test
+ void testBsAndSfAreIncompatible() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/"));
+ request.addHeader("Example", "a=1");
+ final StructuredFieldParameters parameters = StructuredFieldParameters.builder()
+ .putBoolean("bs", true)
+ .putBoolean("sf", true)
+ .build();
+ final MessageSignatureInput input = new MessageSignatureInput("bad", Arrays.asList(
+ MessageSignatureComponent.create("example", parameters)), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ @Test
+ void testUnknownComponentParameterFails() throws Exception {
+ final BasicHttpRequest request = testRequest();
+ final StructuredFieldParameters parameters = StructuredFieldParameters.builder()
+ .putBoolean("future", true)
+ .build();
+ final MessageSignatureInput input = new MessageSignatureInput("bad", Arrays.asList(
+ MessageSignatureComponent.create("content-type", parameters)), StructuredFieldParameters.EMPTY);
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> builder.build(input, MessageSignatureContext.builder(request).build()));
+ }
+
+ private static BasicHttpRequest testRequest() {
+ final BasicHttpRequest request = new BasicHttpRequest(
+ "POST", URI.create("https://example.com/foo?param=Value&Pet=dog"));
+ request.addHeader("Date", "Tue, 20 Apr 2021 02:07:55 GMT");
+ request.addHeader("Content-Type", "application/json");
+ request.addHeader("Content-Digest", CONTENT_DIGEST);
+ request.addHeader("Content-Length", "18");
+ return request;
+ }
+
+}
diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureFields.java b/httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureFields.java
new file mode 100644
index 000000000..4e98ee3ba
--- /dev/null
+++ b/httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureFields.java
@@ -0,0 +1,199 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.signature;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.structured.StructuredFieldBareItem;
+import org.apache.hc.core5.http.structured.StructuredFieldParameters;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestMessageSignatureFields {
+
+ @Test
+ void testParseAndSerializeSignatureInput() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/"));
+ request.addHeader("Signature-Input",
+ "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-digest\" \"cache-control\")"
+ + ";created=1618884475;keyid=\"test-key-rsa-pss\"");
+
+ final List parsed = MessageSignatureFields.parseSignatureInput(request);
+ Assertions.assertEquals(1, parsed.size());
+ Assertions.assertEquals("sig1", parsed.get(0).getLabel());
+ Assertions.assertEquals(5, parsed.get(0).getComponents().size());
+ Assertions.assertEquals("@method", parsed.get(0).getComponents().get(0).getName());
+ Assertions.assertEquals("cache-control", parsed.get(0).getComponents().get(4).getName());
+ Assertions.assertEquals(1618884475L,
+ parsed.get(0).getParameters().get("created").getLongValue());
+
+ final Header formatted = MessageSignatureFields.formatSignatureInput(parsed);
+ Assertions.assertEquals(
+ "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-digest\" \"cache-control\")"
+ + ";created=1618884475;keyid=\"test-key-rsa-pss\"",
+ formatted.getValue());
+ }
+
+ @Test
+ void testParseAndSerializeSignature() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", URI.create("https://example.com/"));
+ request.addHeader("Signature", "sig1=:AQID:, sig2=:BAUG:");
+
+ final List parsed = MessageSignatureFields.parseSignature(request);
+ Assertions.assertEquals(2, parsed.size());
+ Assertions.assertArrayEquals(new byte[] {1, 2, 3}, parsed.get(0).getValue());
+ Assertions.assertArrayEquals(new byte[] {4, 5, 6}, parsed.get(1).getValue());
+
+ final Header formatted = MessageSignatureFields.formatSignature(parsed);
+ Assertions.assertEquals("sig1=:AQID:, sig2=:BAUG:", formatted.getValue());
+ }
+
+ @Test
+ void testSignatureInputLabelDuplicateAcrossFieldLinesFails() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature-Input", "sig1=(\"@method\")");
+ request.addHeader("Signature-Input", "sig1=(\"@path\")");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignatureInput(request));
+ }
+
+ @Test
+ void testSignatureInputLabelDuplicateWithinOneFieldLineFails() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature-Input", "sig1=(\"@method\"), sig1=(\"@path\")");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignatureInput(request));
+ }
+
+
+ @Test
+ void testSignatureLabelDuplicateAcrossFieldLinesFails() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature", "sig1=:AQID:");
+ request.addHeader("Signature", "sig1=:BAUG:");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignature(request));
+ }
+
+ @Test
+ void testSignatureLabelDuplicateWithinOneFieldLineFails() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature", "sig1=:AQID:, sig1=:BAUG:");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignature(request));
+ }
+
+ @Test
+ void testProgrammaticSignatureLabelUsesStructuredFieldKeyGrammar() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MessageSignature("Sig1", new byte[] {1}));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MessageSignatureInput("sig/1",
+ Arrays.asList(MessageSignatureComponent.derived("@method")),
+ StructuredFieldParameters.EMPTY));
+ }
+
+ @Test
+ void testMatchingLabelsRejectsDuplicateProgrammaticLabels() throws Exception {
+ final MessageSignatureInput input = new MessageSignatureInput("sig1",
+ Arrays.asList(MessageSignatureComponent.derived("@method")), StructuredFieldParameters.EMPTY);
+ final MessageSignature signature = new MessageSignature("sig1", new byte[] {1});
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> MessageSignatureFields.validateMatchingLabels(
+ Arrays.asList(input, input), Arrays.asList(signature, signature)));
+ }
+
+ @Test
+ void testSignatureMustBeByteSequence() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature", "sig1=\"not-bytes\"");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignature(request));
+ }
+
+ @Test
+ void testKnownSignatureParameterTypesAreValidated() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature-Input", "sig1=(\"@method\");created=\"wrong\"");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignatureInput(request));
+ }
+
+ @Test
+ void testMatchingLabels() throws Exception {
+ final MessageSignatureInput input = new MessageSignatureInput("sig1",
+ Arrays.asList(MessageSignatureComponent.derived("@method")),
+ StructuredFieldParameters.builder()
+ .put("created", StructuredFieldBareItem.ofInteger(1))
+ .build());
+ MessageSignatureFields.validateMatchingLabels(
+ Arrays.asList(input), Arrays.asList(new MessageSignature("sig1", new byte[] {1})));
+ Assertions.assertThrows(MessageSignatureException.class,
+ () -> MessageSignatureFields.validateMatchingLabels(
+ Arrays.asList(input), Arrays.asList(new MessageSignature("sig2", new byte[] {1}))));
+ }
+
+ @Test
+ void testFieldComponentNamesMustBeLowerCaseWhenParsed() {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Signature-Input", "sig1=(\"Content-Type\")");
+ Assertions.assertThrows(ParseException.class,
+ () -> MessageSignatureFields.parseSignatureInput(request));
+ }
+
+ @Test
+ void testMessageSignatureValueSemantics() {
+ final MessageSignature first = new MessageSignature("sig1", new byte[] {1, 2, 3});
+ final MessageSignature second = new MessageSignature("sig1", new byte[] {1, 2, 3});
+ Assertions.assertEquals(first, second);
+ Assertions.assertEquals(first.hashCode(), second.hashCode());
+ Assertions.assertEquals("sig1=:AQID:", first.toString());
+ }
+
+ @Test
+ void testMessageSignatureInputValueSemantics() {
+ final StructuredFieldParameters parameters = StructuredFieldParameters.builder()
+ .put("created", StructuredFieldBareItem.ofInteger(1618884475L))
+ .build();
+ final MessageSignatureInput first = new MessageSignatureInput("sig1", Arrays.asList(
+ MessageSignatureComponent.derived("@method"),
+ MessageSignatureComponent.field("content-digest")), parameters);
+ final MessageSignatureInput second = new MessageSignatureInput("sig1", Arrays.asList(
+ MessageSignatureComponent.derived("@method"),
+ MessageSignatureComponent.field("content-digest")), parameters);
+ Assertions.assertEquals(first, second);
+ Assertions.assertEquals(first.hashCode(), second.hashCode());
+ Assertions.assertEquals(
+ "sig1=(\"@method\" \"content-digest\");created=1618884475", first.toString());
+ }
+
+}
diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/structured/TestStructuredFieldHeaders.java b/httpcore5/src/test/java/org/apache/hc/core5/http/structured/TestStructuredFieldHeaders.java
new file mode 100644
index 000000000..31eaf9dc0
--- /dev/null
+++ b/httpcore5/src/test/java/org/apache/hc/core5/http/structured/TestStructuredFieldHeaders.java
@@ -0,0 +1,69 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.http.structured;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestStructuredFieldHeaders {
+
+ @Test
+ void testDictionaryEntriesPreserveOrderAndDuplicates() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Example", "a=1, b=2");
+ request.addHeader("Example", "a=3");
+
+ final List> entries =
+ StructuredFieldHeaders.parseDictionaryEntries(request, "Example");
+
+ Assertions.assertEquals(3, entries.size());
+ Assertions.assertEquals("a", entries.get(0).getKey());
+ Assertions.assertEquals(1L, ((StructuredFieldItem) entries.get(0).getValue()).getBareItem().getLongValue());
+ Assertions.assertEquals("b", entries.get(1).getKey());
+ Assertions.assertEquals(2L, ((StructuredFieldItem) entries.get(1).getValue()).getBareItem().getLongValue());
+ Assertions.assertEquals("a", entries.get(2).getKey());
+ Assertions.assertEquals(3L, ((StructuredFieldItem) entries.get(2).getValue()).getBareItem().getLongValue());
+ }
+
+ @Test
+ void testDictionaryStillUsesLastValueWinsSemantics() throws Exception {
+ final BasicHttpRequest request = new BasicHttpRequest("GET", "/");
+ request.addHeader("Example", "a=1, b=2, a=3");
+
+ final StructuredFieldDictionary dictionary = StructuredFieldHeaders.parseDictionary(request, "Example");
+
+ Assertions.assertEquals(2, dictionary.size());
+ Assertions.assertEquals("a", dictionary.getName(0));
+ Assertions.assertEquals(3L, ((StructuredFieldItem) dictionary.get("a")).getBareItem().getLongValue());
+ Assertions.assertEquals("b", dictionary.getName(1));
+ }
+
+}