From 91e5478b5f245a66e3ea5a96eda9a86da7097a04 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Wed, 16 Sep 2026 15:30:12 +0200 Subject: [PATCH] RFC 9421: HTTP Message Signatures Signature base canonicalization and the Signature-Input/Signature field model: covered components, derived components, and the ;sf/;key/;bs/;req/;tr parameters. Cryptographic signing, verification, and key management are out of scope. --- ...aultMessageSignatureComponentResolver.java | 497 ++++++++++++++++++ .../http/signature/MessageSignature.java | 106 ++++ .../MessageSignatureBaseBuilder.java | 125 +++++ .../signature/MessageSignatureComponent.java | 203 +++++++ .../MessageSignatureComponentResolver.java | 48 ++ .../signature/MessageSignatureContext.java | 192 +++++++ .../signature/MessageSignatureException.java | 58 ++ .../signature/MessageSignatureFields.java | 197 +++++++ .../http/signature/MessageSignatureInput.java | 186 +++++++ .../signature/MessageSignatureSupport.java | 44 ++ .../signature/StructuredFieldValueType.java | 56 ++ .../hc/core5/http/signature/package-info.java | 31 ++ .../structured/StructuredFieldHeaders.java | 34 +- ...ssageSignatureCanonicalizationExample.java | 81 +++ .../TestMessageSignatureBaseBuilder.java | 443 ++++++++++++++++ .../signature/TestMessageSignatureFields.java | 199 +++++++ .../TestStructuredFieldHeaders.java | 69 +++ 17 files changed, 2564 insertions(+), 5 deletions(-) create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/DefaultMessageSignatureComponentResolver.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignature.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureBaseBuilder.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponent.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureComponentResolver.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureContext.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureException.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureFields.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureInput.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/MessageSignatureSupport.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/StructuredFieldValueType.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/signature/package-info.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/examples/MessageSignatureCanonicalizationExample.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureBaseBuilder.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/signature/TestMessageSignatureFields.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/structured/TestStructuredFieldHeaders.java 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)); + } + +}