CookieSaslMechanism.java

/*
 * SPDX-FileCopyrightText: 2023-2025 Lucimber UG
 * SPDX-License-Identifier: Apache-2.0
 */
package com.lucimber.dbus.netty.sasl;

import io.netty.channel.ChannelHandlerContext;
import io.netty.util.concurrent.EventExecutor;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GlobalEventExecutor;
import io.netty.util.concurrent.Promise;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.SecureRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * SASL mechanism implementation for D-Bus COOKIE_SHA1 authentication.
 *
 * <p><strong>Security Warning:</strong> This mechanism uses SHA-1 hashing as mandated by the D-Bus
 * specification. While SHA-1 is cryptographically weak, it cannot be replaced without breaking
 * compatibility with the D-Bus protocol standard.
 *
 * <p>The DBUS_COOKIE_SHA1 mechanism is defined in the D-Bus specification and requires SHA-1 for
 * compatibility with all D-Bus implementations. This is a protocol-level requirement, not an
 * implementation choice.
 *
 * @see <a href="https://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-sha">D-Bus
 *     Authentication Mechanisms</a>
 * @since 1.0.0
 */
public final class CookieSaslMechanism implements SaslMechanism {

    private static final Logger LOGGER = LoggerFactory.getLogger(CookieSaslMechanism.class);

    private String username;
    private String clientChallenge;
    private boolean completed = false;

    @Override
    public String getName() {
        return "DBUS_COOKIE_SHA1";
    }

    @Override
    public void init(ChannelHandlerContext ctx) throws SaslMechanismException {
        username = System.getProperty("user.name");
        if (username == null || username.isEmpty()) {
            throw new SaslMechanismException("Username could not be determined.");
        }
        byte[] nonce = new byte[16];
        new SecureRandom().nextBytes(nonce);
        clientChallenge = SaslUtil.hexEncode(nonce);
    }

    @Override
    public Future<String> getInitialResponseAsync(ChannelHandlerContext ctx) {
        Promise<String> promise = ctx.executor().newPromise();
        findWorkerExecutor(ctx)
                .execute(
                        () -> {
                            try {
                                String hexUsername =
                                        SaslUtil.hexEncode(
                                                username.getBytes(StandardCharsets.UTF_8));
                                promise.setSuccess(hexUsername);
                            } catch (Exception e) {
                                promise.setFailure(
                                        new SaslMechanismException(
                                                "Failed to encode username.", e));
                            }
                        });
        return promise;
    }

    @Override
    public Future<String> processChallengeAsync(ChannelHandlerContext ctx, String challengeHex) {
        Promise<String> promise = ctx.executor().newPromise();
        findWorkerExecutor(ctx)
                .execute(
                        () -> {
                            try {
                                String decoded =
                                        new String(
                                                SaslUtil.hexDecode(challengeHex),
                                                StandardCharsets.UTF_8);
                                String[] parts = decoded.split(" ");
                                if (parts.length != 3) {
                                    promise.setFailure(
                                            new SaslMechanismException(
                                                    "Invalid server challenge format. Expected 3 fields."));
                                    return;
                                }

                                String context = parts[0];
                                String cookieId = parts[1];
                                String serverChallenge = parts[2];

                                String cookieValue = readCookieValue(context, cookieId);
                                if (cookieValue == null) {
                                    promise.setFailure(
                                            new SaslMechanismException(
                                                    "Cookie not found for id: " + cookieId));
                                    return;
                                }

                                String combined =
                                        serverChallenge + ":" + clientChallenge + ":" + cookieValue;

                                // NOTE: SHA-1 is required by D-Bus COOKIE_SHA1 specification -
                                // cannot be changed
                                // without breaking protocol compatibility. This is a protocol
                                // requirement.
                                MessageDigest digest = MessageDigest.getInstance("SHA-1");
                                String responseHash =
                                        SaslUtil.hexEncode(
                                                digest.digest(
                                                        combined.getBytes(StandardCharsets.UTF_8)));

                                String response = clientChallenge + " " + responseHash;
                                String hexResponse =
                                        SaslUtil.hexEncode(
                                                response.getBytes(StandardCharsets.UTF_8));
                                completed = true;
                                promise.setSuccess(hexResponse);
                            } catch (Exception e) {
                                promise.setFailure(
                                        new SaslMechanismException(
                                                "Error processing server challenge.", e));
                            }
                        });
        return promise;
    }

    private String readCookieValue(String context, String cookieId) throws IOException {
        // Validate context to prevent path traversal attacks
        if (context == null || context.isEmpty()) {
            throw new IOException("Cookie context cannot be null or empty");
        }

        // Sanitize context - only allow alphanumeric characters, dots, hyphens, and underscores
        if (!context.matches("^[a-zA-Z0-9._-]+$")) {
            throw new IOException("Invalid cookie context format: contains illegal characters");
        }

        // Prevent path traversal sequences
        if (context.contains("..") || context.contains("/") || context.contains("\\")) {
            throw new IOException("Cookie context contains path traversal sequences");
        }

        Path dbusKeyringsDir = Paths.get(System.getProperty("user.home"), ".dbus-keyrings");
        Path cookieFile = dbusKeyringsDir.resolve(context);

        // Ensure the resolved path is still within the .dbus-keyrings directory
        if (!cookieFile.startsWith(dbusKeyringsDir)) {
            throw new IOException("Cookie file path is outside the .dbus-keyrings directory");
        }

        if (!Files.exists(cookieFile) || !Files.isReadable(cookieFile)) {
            LOGGER.warn("Cookie file not found or unreadable: {}", cookieFile);
            return null;
        }

        try (BufferedReader reader = Files.newBufferedReader(cookieFile, StandardCharsets.UTF_8)) {
            String line;
            while ((line = reader.readLine()) != null) {
                String[] parts = line.trim().split(" ", 3);
                if (parts.length == 3 && parts[0].equals(cookieId)) {
                    return parts[2];
                }
            }
        }

        LOGGER.warn("No matching cookie ID {} in file {}", cookieId, cookieFile);
        return null;
    }

    @Override
    public boolean isComplete() {
        return completed;
    }

    @Override
    public void dispose() {
        username = null;
        clientChallenge = null;
        completed = false;
    }

    private EventExecutor findWorkerExecutor(ChannelHandlerContext ctx) {
        return GlobalEventExecutor.INSTANCE;
    }
}