This is an automated email from the ASF dual-hosted git repository. quantranhong1999 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit ce7294753417b9a4da55b4d3941a956b9bc72462 Author: Quan Tran <[email protected]> AuthorDate: Thu Aug 13 09:40:49 2026 +0700 JAMES-4210 Add RFC 5804 ManageSieve SASL codec Translate ManageSieve quoted and literal authentication data to protocol-neutral SASL exchanges. Encode challenges and final server data using RFC 5804 framing while retaining legacy raw PLAIN input compatibility. --- .../managesieve/sasl/ManageSieveSaslCodec.java | 222 +++++++++++++++++++++ .../managesieve/sasl/ManageSieveSaslCodecTest.java | 160 +++++++++++++++ 2 files changed, 382 insertions(+) diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/sasl/ManageSieveSaslCodec.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/sasl/ManageSieveSaslCodec.java new file mode 100644 index 0000000000..4efa7a7eb4 --- /dev/null +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/sasl/ManageSieveSaslCodec.java @@ -0,0 +1,222 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieve.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.apache.james.managesieve.api.SyntaxException; +import org.apache.james.managesieve.transcode.NotEnoughDataException; +import org.apache.james.protocols.api.sasl.SaslCodec; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; + +/** + * Translates between ManageSieve AUTHENTICATE strings and protocol-neutral SASL exchanges. + * + * RFC 5804 section 2.1 frames SASL mechanism names, initial responses and continuations as + * ManageSieve quoted or literal strings. This differs from the line framing used by IMAP, + * SMTP and POP3, so this codec owns the ManageSieve string grammar and response syntax while + * delegating protocol-neutral Base64 and cancellation handling to {@link SaslCodec}. + */ +public class ManageSieveSaslCodec { + public record InitialRequest(String mechanismName, SaslInitialRequest saslInitialRequest) { + } + + private record ParsedString(String value, String remaining) { + } + + private static final int QUOTED_STRING_MAX_LENGTH = 1024; + + public static InitialRequest parseInitialRequest(String arguments) throws SyntaxException { + ParsedString mechanism = parseString(arguments, "quoted SASL mechanism must be supplied"); + String remaining = mechanism.remaining().stripLeading(); + Optional<byte[]> initialResponse = Optional.empty(); + if (!remaining.isEmpty()) { + ParsedString response = parseString(remaining, "authentication data must be supplied"); + if (!response.remaining().isBlank()) { + throw new SyntaxException("too many authentication arguments"); + } + initialResponse = Optional.of(decodeClientData(mechanism.value(), response.value())); + } + return new InitialRequest(mechanism.value(), new SaslInitialRequest(mechanism.value(), initialResponse)); + } + + public static byte[] parseClientResponse(String mechanismName, String suppliedData) throws SyntaxException { + if (suppliedData.isEmpty()) { + throw new SyntaxException("authentication data must be supplied"); + } + String serializedData = isString(suppliedData) + ? parseContinuationString(suppliedData) + : suppliedData; + return decodeClientData(mechanismName, serializedData); + } + + public static boolean isAbort(String suppliedData) { + String clientData = suppliedData; + try { + if (isString(suppliedData)) { + clientData = parseContinuationString(suppliedData); + } + return SaslCodec.isAbort(clientData.getBytes(StandardCharsets.US_ASCII)); + } catch (SyntaxException e) { + return false; + } + } + + public static String challenge(SaslStep.Challenge challenge) { + // RFC 5804 section 2.1 encodes SASL challenges as ManageSieve strings, including an empty challenge as "". + return serializeBase64(challenge.payload()); + } + + public static String success(SaslStep.Success success) { + return success.serverData() + .map(serverData -> "OK (SASL " + serializeBase64(Optional.of(serverData)) + ")") + .orElse("OK"); + } + + private static String parseContinuationString(String suppliedData) throws SyntaxException { + ParsedString parsedString = parseString(suppliedData, "authentication data must be supplied"); + if (!parsedString.remaining().isBlank()) { + throw new SyntaxException("too many authentication arguments"); + } + return parsedString.value(); + } + + private static byte[] decodeClientData(String mechanismName, String suppliedData) throws SyntaxException { + try { + return SaslCodec.decodeClientResponse(suppliedData.getBytes(StandardCharsets.US_ASCII)); + } catch (IllegalArgumentException e) { + if (mechanismName.equalsIgnoreCase(SaslMechanismNames.PLAIN)) { + return legacyPlainData(suppliedData); + } + throw new SyntaxException("authentication data must be Base64 encoded"); + } + } + + private static byte[] legacyPlainData(String suppliedData) throws SyntaxException { + String separator = suppliedData.indexOf('\0') >= 0 ? "\0" : "\\s+"; + List<String> tokens = Arrays.stream(suppliedData.split(separator, -1)) + .filter(token -> !token.isEmpty()) + .limit(2) + .toList(); + if (tokens.size() < 2) { + throw new SyntaxException("authentication data is malformed"); + } + return (tokens.get(0) + '\0' + tokens.get(1)).getBytes(StandardCharsets.UTF_8); + } + + private static String serializeBase64(Optional<byte[]> payload) { + String encoded = SaslCodec.encode(payload); + if (encoded.length() <= QUOTED_STRING_MAX_LENGTH) { + return '"' + encoded + '"'; + } + return "{" + encoded.length() + "}\r\n" + encoded; + } + + private static ParsedString parseString(String input, String missingMessage) throws SyntaxException { + if (input == null || input.isEmpty()) { + throw new SyntaxException(missingMessage); + } + if (input.charAt(0) == '"' || input.charAt(0) == '\'') { + return parseQuotedString(input); + } + if (input.charAt(0) == '{') { + return parseLiteral(input); + } + throw new SyntaxException(missingMessage); + } + + private static ParsedString parseQuotedString(String input) throws SyntaxException { + char quote = input.charAt(0); + StringBuilder value = new StringBuilder(); + boolean escaped = false; + for (int index = 1; index < input.length(); index++) { + char current = input.charAt(index); + if (escaped) { + value.append(current); + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + return new ParsedString(value.toString(), input.substring(index + 1)); + } else { + value.append(current); + } + } + throw new SyntaxException("unterminated quoted authentication argument"); + } + + private static ParsedString parseLiteral(String input) throws SyntaxException { + int markerEnd = input.indexOf("}\r\n"); + if (markerEnd < 0) { + throw new NotEnoughDataException(); + } + String sizeValue = input.substring(1, markerEnd); + // RFC 5804 section 4 defines client-to-server literals as {number+}; {number} is server-to-client syntax. + if (!sizeValue.endsWith("+")) { + throw new SyntaxException("invalid client authentication literal syntax"); + } + sizeValue = sizeValue.substring(0, sizeValue.length() - 1); + if (sizeValue.isEmpty() || sizeValue.chars().anyMatch(character -> character < '0' || character > '9')) { + throw new SyntaxException("invalid authentication literal size"); + } + int size; + try { + size = Integer.parseInt(sizeValue); + } catch (NumberFormatException e) { + throw new SyntaxException("invalid authentication literal size"); + } + int contentStart = markerEnd + 3; + // RFC 5804 section 4 defines the literal size as an octet count, not a Java character count. + int contentEnd = utf8ContentEnd(input, contentStart, size); + return new ParsedString(input.substring(contentStart, contentEnd), input.substring(contentEnd)); + } + + private static int utf8ContentEnd(String input, int contentStart, int expectedOctets) throws SyntaxException { + int contentEnd = contentStart; + int consumedOctets = 0; + while (contentEnd < input.length() && consumedOctets < expectedOctets) { + int codePoint = input.codePointAt(contentEnd); + int codePointOctets = utf8Length(codePoint); + if (consumedOctets + codePointOctets > expectedOctets) { + throw new SyntaxException("authentication literal size splits a UTF-8 character"); + } + consumedOctets += codePointOctets; + contentEnd += Character.charCount(codePoint); + } + if (consumedOctets < expectedOctets) { + throw new NotEnoughDataException(); + } + return contentEnd; + } + + private static int utf8Length(int codePoint) { + return new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8).length; + } + + private static boolean isString(String suppliedData) { + return !suppliedData.isEmpty() + && (suppliedData.charAt(0) == '"' || suppliedData.charAt(0) == '\'' || suppliedData.charAt(0) == '{'); + } +} diff --git a/protocols/managesieve/src/test/java/org/apache/james/managesieve/sasl/ManageSieveSaslCodecTest.java b/protocols/managesieve/src/test/java/org/apache/james/managesieve/sasl/ManageSieveSaslCodecTest.java new file mode 100644 index 0000000000..00b1c9f6be --- /dev/null +++ b/protocols/managesieve/src/test/java/org/apache/james/managesieve/sasl/ManageSieveSaslCodecTest.java @@ -0,0 +1,160 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieve.sasl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.managesieve.api.SyntaxException; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class ManageSieveSaslCodecTest { + private static final SaslIdentity IDENTITY = new SaslIdentity(Username.of("bob"), Username.of("bob")); + + @Test + void parseInitialRequestShouldDistinguishAbsentResponse() throws Exception { + ManageSieveSaslCodec.InitialRequest request = ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\""); + + assertThat(request.mechanismName()).isEqualTo("PLAIN"); + assertThat(request.saslInitialRequest().initialResponse()).isEmpty(); + } + + @Test + void parseInitialRequestShouldPreserveExplicitEmptyResponse() throws Exception { + ManageSieveSaslCodec.InitialRequest request = ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\" \"\""); + + assertThat(request.saslInitialRequest().initialResponse()).hasValueSatisfying(response -> assertThat(response).isEmpty()); + } + + @Test + void parseInitialRequestShouldDecodeQuotedResponse() throws Exception { + ManageSieveSaslCodec.InitialRequest request = ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\" \"dXNlcgBwYXNz\""); + + assertThat(request.saslInitialRequest().initialResponse()).hasValueSatisfying(response -> + assertThat(response).containsExactly(bytes("user\0pass"))); + } + + @Test + void parseInitialRequestShouldDecodeLiteralResponse() throws Exception { + ManageSieveSaslCodec.InitialRequest request = ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\" {12+}\r\ndXNlcgBwYXNz"); + + assertThat(request.saslInitialRequest().initialResponse()).hasValueSatisfying(response -> + assertThat(response).containsExactly(bytes("user\0pass"))); + } + + @Test + void parseInitialRequestShouldMeasureLiteralSizeInUtf8Octets() throws Exception { + ManageSieveSaslCodec.InitialRequest request = ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\" {4+}\r\né p"); + + assertThat(request.saslInitialRequest().initialResponse()).hasValueSatisfying(response -> + assertThat(response).containsExactly(bytes("é\0p"))); + } + + @Test + void parseInitialRequestShouldRejectLiteralSizeSplittingUtf8Character() { + assertThatThrownBy(() -> ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\" {1+}\r\né")) + .isInstanceOf(SyntaxException.class) + .hasMessage("authentication literal size splits a UTF-8 character"); + } + + @Test + void parseInitialRequestShouldRejectServerToClientLiteralSyntax() { + assertThatThrownBy(() -> ManageSieveSaslCodec.parseInitialRequest("\"PLAIN\" {12}\r\ndXNlcgBwYXNz")) + .isInstanceOf(SyntaxException.class) + .hasMessage("invalid client authentication literal syntax"); + } + + @Test + void parseInitialRequestShouldRejectNegativeLiteralSize() { + assertThatThrownBy(() -> ManageSieveSaslCodec.parseInitialRequest("{-1+}\r\n")) + .isInstanceOf(SyntaxException.class) + .hasMessage("invalid authentication literal size"); + } + + @Test + void parseInitialRequestShouldRejectOverflowingLiteralSize() { + assertThatThrownBy(() -> ManageSieveSaslCodec.parseInitialRequest("{2147483648+}\r\n")) + .isInstanceOf(SyntaxException.class) + .hasMessage("invalid authentication literal size"); + } + + @Test + void parseClientResponseShouldAcceptLegacyRawPlainData() throws Exception { + assertThat(ManageSieveSaslCodec.parseClientResponse("PLAIN", " user password")) + .containsExactly(bytes("user\0password")); + } + + @Test + void parseClientResponseShouldRejectRawDataForOtherMechanisms() { + assertThatThrownBy(() -> ManageSieveSaslCodec.parseClientResponse("CUSTOM", "not base64 data")) + .isInstanceOf(SyntaxException.class) + .hasMessage("authentication data must be Base64 encoded"); + } + + @Test + void parseClientResponseShouldAcceptUnpaddedBase64() throws Exception { + assertThat(ManageSieveSaslCodec.parseClientResponse("CUSTOM", "dXNlcg")) + .containsExactly(bytes("user")); + } + + @Test + void challengeShouldSerializeEmptyPayloadAsQuotedString() { + assertThat(ManageSieveSaslCodec.challenge(new SaslStep.Challenge(Optional.empty()))) + .isEqualTo("\"\""); + } + + @Test + void challengeShouldSerializePayloadAsQuotedBase64() { + assertThat(ManageSieveSaslCodec.challenge(new SaslStep.Challenge(Optional.of(bytes("challenge"))))) + .isEqualTo("\"Y2hhbGxlbmdl\""); + } + + @Test + void challengeShouldUseLiteralForLargePayload() { + byte[] payload = new byte[769]; + String encoded = Base64.getEncoder().encodeToString(payload); + + assertThat(ManageSieveSaslCodec.challenge(new SaslStep.Challenge(Optional.of(payload)))) + .isEqualTo("{" + encoded.length() + "}\r\n" + encoded); + } + + @Test + void successShouldSerializeFinalServerData() { + assertThat(ManageSieveSaslCodec.success(new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data"))))) + .isEqualTo("OK (SASL \"c2VydmVyLWRhdGE=\")"); + } + + @Test + void isAbortShouldAcceptBareAndQuotedCancellation() { + assertThat(ManageSieveSaslCodec.isAbort("*")).isTrue(); + assertThat(ManageSieveSaslCodec.isAbort("\"*\"")).isTrue(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
