| /* |
| * 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.sshd.common.session.helpers; |
| |
| import java.io.IOException; |
| import java.io.InterruptedIOException; |
| import java.net.ProtocolException; |
| import java.net.SocketTimeoutException; |
| import java.nio.charset.StandardCharsets; |
| import java.security.GeneralSecurityException; |
| import java.time.Duration; |
| import java.time.Instant; |
| import java.util.AbstractMap.SimpleImmutableEntry; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.Deque; |
| import java.util.EnumMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.ConcurrentLinkedDeque; |
| import java.util.concurrent.CopyOnWriteArraySet; |
| import java.util.concurrent.ScheduledExecutorService; |
| import java.util.concurrent.ScheduledFuture; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.TimeoutException; |
| import java.util.concurrent.atomic.AtomicLong; |
| import java.util.concurrent.atomic.AtomicReference; |
| import java.util.function.LongConsumer; |
| import java.util.logging.Level; |
| |
| import org.apache.sshd.common.Closeable; |
| import org.apache.sshd.common.Factory; |
| import org.apache.sshd.common.FactoryManager; |
| import org.apache.sshd.common.NamedFactory; |
| import org.apache.sshd.common.NamedResource; |
| import org.apache.sshd.common.RuntimeSshException; |
| import org.apache.sshd.common.Service; |
| import org.apache.sshd.common.SshConstants; |
| import org.apache.sshd.common.SshException; |
| import org.apache.sshd.common.channel.ChannelListener; |
| import org.apache.sshd.common.cipher.Cipher; |
| import org.apache.sshd.common.cipher.CipherInformation; |
| import org.apache.sshd.common.compression.Compression; |
| import org.apache.sshd.common.compression.CompressionInformation; |
| import org.apache.sshd.common.digest.Digest; |
| import org.apache.sshd.common.forward.PortForwardingEventListener; |
| import org.apache.sshd.common.future.DefaultKeyExchangeFuture; |
| import org.apache.sshd.common.future.DefaultSshFuture; |
| import org.apache.sshd.common.future.GlobalRequestFuture; |
| import org.apache.sshd.common.future.KeyExchangeFuture; |
| import org.apache.sshd.common.global.GlobalRequestException; |
| import org.apache.sshd.common.io.IoSession; |
| import org.apache.sshd.common.io.IoWriteFuture; |
| import org.apache.sshd.common.kex.KexProposalOption; |
| import org.apache.sshd.common.kex.KexState; |
| import org.apache.sshd.common.kex.KeyExchange; |
| import org.apache.sshd.common.kex.KeyExchangeFactory; |
| import org.apache.sshd.common.kex.extension.KexExtensionHandler; |
| import org.apache.sshd.common.kex.extension.KexExtensionHandler.AvailabilityPhase; |
| import org.apache.sshd.common.kex.extension.KexExtensionHandler.KexPhase; |
| import org.apache.sshd.common.kex.extension.KexExtensions; |
| import org.apache.sshd.common.mac.Mac; |
| import org.apache.sshd.common.mac.MacInformation; |
| import org.apache.sshd.common.random.Random; |
| import org.apache.sshd.common.session.ReservedSessionMessagesHandler; |
| import org.apache.sshd.common.session.SessionDisconnectHandler; |
| import org.apache.sshd.common.session.SessionListener; |
| import org.apache.sshd.common.session.SessionWorkBuffer; |
| import org.apache.sshd.common.util.EventListenerUtils; |
| import org.apache.sshd.common.util.ExceptionUtils; |
| import org.apache.sshd.common.util.GenericUtils; |
| import org.apache.sshd.common.util.NumberUtils; |
| import org.apache.sshd.common.util.Readable; |
| import org.apache.sshd.common.util.ValidateUtils; |
| import org.apache.sshd.common.util.buffer.Buffer; |
| import org.apache.sshd.common.util.buffer.BufferUtils; |
| import org.apache.sshd.common.util.buffer.ByteArrayBuffer; |
| import org.apache.sshd.common.util.threads.ThreadUtils; |
| import org.apache.sshd.core.CoreModuleProperties; |
| |
| /** |
| * <P> |
| * The AbstractSession handles all the basic SSH protocol such as key exchange, authentication, encoding and decoding. |
| * Both server side and client side sessions should inherit from this abstract class. Some basic packet processing |
| * methods are defined but the actual call to these methods should be done from the {@link #handleMessage(Buffer)} |
| * method, which is dependent on the state and side of this session. |
| * </P> |
| * |
| * TODO: if there is any very big packet, decoderBuffer and uncompressBuffer will get quite big and they won't be |
| * resized down at any time. Though the packet size is really limited by the channel max packet size |
| * |
| * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a> |
| */ |
| public abstract class AbstractSession extends SessionHelper { |
| /** |
| * Name of the property where this session is stored in the attributes of the underlying MINA session. See |
| * {@link #getSession(IoSession, boolean)} and {@link #attachSession(IoSession, AbstractSession)}. |
| */ |
| public static final String SESSION = "org.apache.sshd.session"; |
| |
| /** |
| * The pseudo random generator |
| */ |
| protected final Random random; |
| |
| /** |
| * Session listeners container |
| */ |
| protected final Collection<SessionListener> sessionListeners = new CopyOnWriteArraySet<>(); |
| protected final SessionListener sessionListenerProxy; |
| |
| /** |
| * Channel events listener container |
| */ |
| protected final Collection<ChannelListener> channelListeners = new CopyOnWriteArraySet<>(); |
| protected final ChannelListener channelListenerProxy; |
| |
| /** |
| * Port forwarding events listener container |
| */ |
| protected final Collection<PortForwardingEventListener> tunnelListeners = new CopyOnWriteArraySet<>(); |
| protected final PortForwardingEventListener tunnelListenerProxy; |
| |
| /* |
| * Key exchange support |
| */ |
| protected byte[] sessionId; |
| protected String serverVersion; |
| protected String clientVersion; |
| // if empty then means not-initialized |
| protected final Map<KexProposalOption, String> serverProposal = new EnumMap<>(KexProposalOption.class); |
| protected final Map<KexProposalOption, String> unmodServerProposal = Collections.unmodifiableMap(serverProposal); |
| protected final Map<KexProposalOption, String> clientProposal = new EnumMap<>(KexProposalOption.class); |
| protected final Map<KexProposalOption, String> unmodClientProposal = Collections.unmodifiableMap(clientProposal); |
| protected final Map<KexProposalOption, String> negotiationResult = new EnumMap<>(KexProposalOption.class); |
| protected final Map<KexProposalOption, String> unmodNegotiationResult = Collections.unmodifiableMap(negotiationResult); |
| |
| protected KeyExchange kex; |
| protected Boolean firstKexPacketFollows; |
| protected boolean initialKexDone; |
| /** |
| * Holds the current key exchange state. |
| */ |
| protected final AtomicReference<KexState> kexState = new AtomicReference<>(KexState.UNKNOWN); |
| protected final AtomicReference<DefaultKeyExchangeFuture> kexFutureHolder = new AtomicReference<>(null); |
| |
| // The kexInitializedFuture is fulfilled when this side (client or server) has prepared its own proposal. Access is |
| // synchronized on kexState. |
| protected DefaultKeyExchangeFuture kexInitializedFuture; |
| |
| /* |
| * SSH packets encoding / decoding support |
| */ |
| protected Cipher outCipher; |
| protected Cipher inCipher; |
| protected int outCipherSize = 8; |
| protected int inCipherSize = 8; |
| protected Mac outMac; |
| protected Mac inMac; |
| protected int outMacSize; |
| protected int inMacSize; |
| protected byte[] inMacResult; |
| protected Compression outCompression; |
| protected Compression inCompression; |
| /** Input packet sequence number. */ |
| protected long seqi; |
| /** Output packet sequence number. */ |
| protected long seqo; |
| protected SessionWorkBuffer uncompressBuffer; |
| protected final SessionWorkBuffer decoderBuffer; |
| protected int decoderState; |
| protected int decoderLength; |
| protected final Object encodeLock = new Object(); |
| protected final Object decodeLock = new Object(); |
| protected final Object requestLock = new Object(); |
| |
| /** |
| * The {@link KeyExchangeMessageHandler} instance also serves as lock protecting {@link #kexState} changes from DONE |
| * to INIT or RUN, and from KEYS to DONE. |
| */ |
| protected final KeyExchangeMessageHandler kexHandler; |
| |
| /* |
| * Rekeying |
| */ |
| protected final AtomicLong inPacketsCount = new AtomicLong(0L); |
| protected final AtomicLong outPacketsCount = new AtomicLong(0L); |
| protected final AtomicLong inBytesCount = new AtomicLong(0L); |
| protected final AtomicLong outBytesCount = new AtomicLong(0L); |
| protected final AtomicLong inBlocksCount = new AtomicLong(0L); |
| protected final AtomicLong outBlocksCount = new AtomicLong(0L); |
| protected final AtomicReference<Instant> lastKeyTimeValue = new AtomicReference<>(Instant.now()); |
| // we initialize them here in case super constructor calls some methods that use these values |
| protected long maxRekyPackets; |
| protected long maxRekeyBytes; |
| protected Duration maxRekeyInterval; |
| |
| /** |
| * Resulting message coding settings at the end of a key exchange for incoming messages. |
| * |
| * @see #prepareNewKeys() |
| * @see #setInputEncoding() |
| */ |
| protected MessageCodingSettings inSettings; |
| |
| /** |
| * Resulting message coding settings at the end of a key exchange for outgoing messages. |
| * |
| * @see #prepareNewKeys() |
| * @see #setOutputEncoding() |
| */ |
| protected MessageCodingSettings outSettings; |
| |
| protected final CurrentService currentService; |
| |
| // SSH_MSG_IGNORE stream padding |
| protected int ignorePacketDataLength; |
| protected long ignorePacketsFrequency; |
| protected int ignorePacketsVariance; |
| |
| protected final AtomicLong maxRekeyBlocks |
| = new AtomicLong(CoreModuleProperties.REKEY_BYTES_LIMIT.getRequiredDefault() / 16); |
| protected final AtomicLong ignorePacketsCount |
| = new AtomicLong(CoreModuleProperties.IGNORE_MESSAGE_FREQUENCY.getRequiredDefault()); |
| |
| /** |
| * Used to wait for results of global requests sent with {@code want-reply = true}. Note that per RFC 4254, global |
| * requests may be sent at any time, but success/failure replies MUST come in the order the requests were sent. Some |
| * implementations may also reply with SSH_MSG_UNIMPLEMENTED, on which RFC 4253 says they must be sent in the order |
| * the message was received. |
| * <p> |
| * This implies that it is legal to send "nested" global requests: a client or server may send two (or more) global |
| * requests, and then receives two (or more) replies in the correct order: first reply for the first request sent; |
| * second reply for the second request sent. |
| * </p> |
| * <p> |
| * We keep a FIFO list of pending global requests for which we expect a reply. We always add new global requests at |
| * the head. For success and failure replies, which don't identify the message sequence number of the global |
| * request, we apply the reply to the tail of the list. For unimplemented messages, we apply it to the request |
| * identified by the message sequence number, which normally also should be the tail. |
| * </p> |
| * <p> |
| * When a reply is received, the corresponding global request is removed from the list. |
| * </p> |
| * <p> |
| * Global requests sent with {@code want-reply = false} are never added to this list; they are fire-and-forget. |
| * According to the SSH RFCs, the peer MUST not reply on a message with {@code want-reply = false}. If it does so |
| * all the same, it is broken. We might then apply the result to the wrong pending global request if we have any. |
| * </p> |
| * |
| * @see <a href="https://tools.ietf.org/html/rfc4254#section-4">RFC 4254: Global Requests</a> |
| * @see <a href="https://tools.ietf.org/html/rfc4253#section-11.4">RFC 4254: Reserved Messages</a> |
| * @see {@link #request(Buffer, String, org.apache.sshd.common.future.GlobalRequestFuture.ReplyHandler)} |
| * @see {@link #requestSuccess(Buffer)} |
| * @see {@link #requestFailure(Buffer)} |
| * @see {@link #doInvokeUnimplementedMessageHandler(int, Buffer)} |
| * @see {@link #preClose()} |
| */ |
| private final Deque<GlobalRequestFuture> pendingGlobalRequests = new ConcurrentLinkedDeque<>(); |
| |
| private final Map<Buffer, LongConsumer> globalSequenceNumbers = new ConcurrentHashMap<>(); |
| |
| private byte[] clientKexData; // the payload of the client's SSH_MSG_KEXINIT |
| private byte[] serverKexData; // the payload of the server's SSH_MSG_KEXINIT |
| |
| /** |
| * Create a new session. |
| * |
| * @param serverSession {@code true} if this is a server session, {@code false} if client one |
| * @param factoryManager the factory manager |
| * @param ioSession the underlying I/O session |
| */ |
| protected AbstractSession(boolean serverSession, FactoryManager factoryManager, IoSession ioSession) { |
| super(serverSession, factoryManager, ioSession); |
| |
| this.decoderBuffer = new SessionWorkBuffer(this); |
| |
| kexHandler = Objects.requireNonNull(initializeKeyExchangeMessageHandler(), |
| "No KeyExchangeMessageHandler set on the session"); |
| currentService = Objects.requireNonNull(initializeCurrentService(), "No CurrentService set on the session"); |
| |
| attachSession(ioSession, this); |
| |
| Factory<? extends Random> factory = ValidateUtils.checkNotNull( |
| factoryManager.getRandomFactory(), "No random factory for %s", ioSession); |
| random = ValidateUtils.checkNotNull( |
| factory.create(), "No randomizer instance for %s", ioSession); |
| |
| refreshConfiguration(); |
| |
| sessionListenerProxy = EventListenerUtils.proxyWrapper( |
| SessionListener.class, sessionListeners); |
| channelListenerProxy = EventListenerUtils.proxyWrapper( |
| ChannelListener.class, channelListeners); |
| tunnelListenerProxy = EventListenerUtils.proxyWrapper( |
| PortForwardingEventListener.class, tunnelListeners); |
| |
| try { |
| signalSessionEstablished(ioSession); |
| } catch (RuntimeException e) { |
| throw e; |
| } catch (Exception e) { |
| throw new RuntimeSshException(e); |
| } |
| } |
| |
| /** |
| * Creates a new {@link KeyExchangeMessageHandler} instance managing packet sending for this session. |
| * <p> |
| * This initialization method is invoked once from the {@link AbstractSession} constructor. Do not rely on subclass |
| * fields being initialized. |
| * </p> |
| * |
| * @return a new {@link KeyExchangeMessageHandler} instance for the session |
| */ |
| protected KeyExchangeMessageHandler initializeKeyExchangeMessageHandler() { |
| return new KeyExchangeMessageHandler(this, log); |
| } |
| |
| /** |
| * Creates a new {@link CurrentService} instance managing this session's current SSH service. |
| * <p> |
| * This initialization method is invoked once from the {@link AbstractSession} constructor. Do not rely on subclass |
| * fields being initialized. |
| * </p> |
| * |
| * @return a new {@link CurrentService} instance for the session |
| */ |
| protected CurrentService initializeCurrentService() { |
| return new CurrentService(this); |
| } |
| |
| /** |
| * @param len The packet payload size |
| * @param blockSize The cipher block size |
| * @param etmMode Whether using "encrypt-then-MAC" mode |
| * @return The required padding length |
| */ |
| public static int calculatePadLength(int len, int blockSize, boolean etmMode) { |
| /* |
| * Note: according to RFC-4253 section 6: |
| * |
| * The minimum size of a packet is 16 (or the cipher block size, whichever is larger) bytes (plus 'mac'). |
| * |
| * Since all out ciphers, MAC(s), etc. have a block size > 8 then the minimum size of the packet will be at |
| * least 16 due to the padding at the very least - so even packets that contain an opcode with no arguments will |
| * be above this value. This avoids an un-necessary call to Math.max(len, 16) for each and every packet |
| */ |
| |
| len++; // the pad length |
| if (!etmMode) { |
| len += Integer.BYTES; |
| } |
| |
| /* |
| * Note: according to RFC-4253 section 6: |
| * |
| * Note that the length of the concatenation of 'packet_length', 'padding_length', 'payload', and 'random |
| * padding' MUST be a multiple of the cipher block size or 8, whichever is larger. |
| * |
| * However, we currently do not have ciphers with a block size of less than 8 so we do not take this into |
| * account in order to accelerate the calculation and avoiding an un-necessary call to Math.max(blockSize, 8) |
| * for each and every packet. |
| */ |
| int pad = (-len) & (blockSize - 1); |
| if (pad < blockSize) { |
| pad += blockSize; |
| } |
| |
| return pad; |
| } |
| |
| @Override |
| public String getServerVersion() { |
| return serverVersion; |
| } |
| |
| @Override |
| public Map<KexProposalOption, String> getServerKexProposals() { |
| return unmodServerProposal; |
| } |
| |
| @Override |
| public String getClientVersion() { |
| return clientVersion; |
| } |
| |
| @Override |
| public Map<KexProposalOption, String> getClientKexProposals() { |
| return unmodClientProposal; |
| } |
| |
| @Override |
| public KeyExchange getKex() { |
| return kex; |
| } |
| |
| @Override |
| public KexState getKexState() { |
| return kexState.get(); |
| } |
| |
| @Override |
| public byte[] getSessionId() { |
| // return a clone to avoid anyone changing the internal value |
| return NumberUtils.isEmpty(sessionId) ? sessionId : sessionId.clone(); |
| } |
| |
| @Override |
| public Map<KexProposalOption, String> getKexNegotiationResult() { |
| return unmodNegotiationResult; |
| } |
| |
| @Override |
| public String getNegotiatedKexParameter(KexProposalOption paramType) { |
| if (paramType == null) { |
| return null; |
| } |
| |
| synchronized (negotiationResult) { |
| return negotiationResult.get(paramType); |
| } |
| } |
| |
| @Override |
| public CipherInformation getCipherInformation(boolean incoming) { |
| return incoming ? inCipher : outCipher; |
| } |
| |
| @Override |
| public CompressionInformation getCompressionInformation(boolean incoming) { |
| return incoming ? inCompression : outCompression; |
| } |
| |
| @Override |
| public MacInformation getMacInformation(boolean incoming) { |
| return incoming ? inMac : outMac; |
| } |
| |
| /** |
| * <P> |
| * Main input point for the MINA framework. |
| * </P> |
| * |
| * <P> |
| * This method will be called each time new data is received on the socket and will append it to the input buffer |
| * before calling the {@link #decode()} method. |
| * </P> |
| * |
| * @param buffer the new buffer received |
| * @throws Exception if an error occurs while decoding or handling the data |
| */ |
| public void messageReceived(Readable buffer) throws Exception { |
| synchronized (decodeLock) { |
| decoderBuffer.putBuffer(buffer); |
| // One of those properties will be set by the constructor and the other |
| // one should be set by the readIdentification method |
| if ((clientVersion == null) || (serverVersion == null)) { |
| if (readIdentification(decoderBuffer)) { |
| decoderBuffer.compact(); |
| } else { |
| return; |
| } |
| } |
| decode(); |
| } |
| } |
| |
| /** |
| * Refresh whatever internal configuration is not {@code final} |
| */ |
| protected void refreshConfiguration() { |
| synchronized (random) { |
| // re-keying configuration |
| maxRekeyBytes = CoreModuleProperties.REKEY_BYTES_LIMIT.getRequired(this); |
| maxRekeyInterval = CoreModuleProperties.REKEY_TIME_LIMIT.getRequired(this); |
| maxRekyPackets = CoreModuleProperties.REKEY_PACKETS_LIMIT.getRequired(this); |
| |
| // intermittent SSH_MSG_IGNORE stream padding |
| ignorePacketDataLength = CoreModuleProperties.IGNORE_MESSAGE_SIZE.getRequired(this); |
| ignorePacketsFrequency = CoreModuleProperties.IGNORE_MESSAGE_FREQUENCY.getRequired(this); |
| ignorePacketsVariance = CoreModuleProperties.IGNORE_MESSAGE_VARIANCE.getRequired(this); |
| if (ignorePacketsVariance >= ignorePacketsFrequency) { |
| ignorePacketsVariance = 0; |
| } |
| |
| long countValue = calculateNextIgnorePacketCount( |
| random, ignorePacketsFrequency, ignorePacketsVariance); |
| ignorePacketsCount.set(countValue); |
| } |
| } |
| |
| /** |
| * Abstract method for processing incoming decoded packets. The given buffer will hold the decoded packet, starting |
| * from the command byte at the read position. |
| * |
| * @param buffer The {@link Buffer} containing the packet - it may be re-used to generate the response once |
| * request has been decoded |
| * @throws Exception if an exception occurs while handling this packet. |
| * @see #doHandleMessage(Buffer) |
| */ |
| protected void handleMessage(Buffer buffer) throws Exception { |
| try { |
| ThreadUtils.runAsInternal(() -> { |
| doHandleMessage(buffer); |
| return null; |
| }); |
| } catch (Throwable e) { |
| DefaultKeyExchangeFuture kexFuture = kexFutureHolder.get(); |
| // if have any ongoing KEX notify it about the failure |
| if (kexFuture != null) { |
| kexFuture.setValue(e); |
| } |
| |
| if (e instanceof Exception) { |
| throw (Exception) e; |
| } else { |
| throw new RuntimeSshException(e); |
| } |
| } |
| } |
| |
| protected void doHandleMessage(Buffer buffer) throws Exception { |
| int cmd = buffer.getUByte(); |
| if (log.isDebugEnabled()) { |
| log.debug("doHandleMessage({}) process #{} {}", this, seqi - 1, |
| SshConstants.getCommandMessageName(cmd)); |
| } |
| |
| switch (cmd) { |
| case SshConstants.SSH_MSG_DISCONNECT: |
| handleDisconnect(buffer); |
| break; |
| case SshConstants.SSH_MSG_IGNORE: |
| handleIgnore(buffer); |
| break; |
| case SshConstants.SSH_MSG_UNIMPLEMENTED: |
| handleUnimplemented(buffer); |
| break; |
| case SshConstants.SSH_MSG_DEBUG: |
| handleDebug(buffer); |
| break; |
| case SshConstants.SSH_MSG_SERVICE_REQUEST: |
| handleServiceRequest(buffer); |
| break; |
| case SshConstants.SSH_MSG_SERVICE_ACCEPT: |
| handleServiceAccept(buffer); |
| break; |
| case SshConstants.SSH_MSG_KEXINIT: |
| handleKexInit(buffer); |
| break; |
| case SshConstants.SSH_MSG_NEWKEYS: |
| handleNewKeys(cmd, buffer); |
| break; |
| case KexExtensions.SSH_MSG_EXT_INFO: |
| handleKexExtension(cmd, buffer); |
| break; |
| case KexExtensions.SSH_MSG_NEWCOMPRESS: |
| handleNewCompression(cmd, buffer); |
| break; |
| default: |
| if ((cmd >= SshConstants.SSH_MSG_KEX_FIRST) && (cmd <= SshConstants.SSH_MSG_KEX_LAST)) { |
| if (firstKexPacketFollows != null) { |
| try { |
| if (!handleFirstKexPacketFollows(cmd, buffer, firstKexPacketFollows)) { |
| break; |
| } |
| } finally { |
| firstKexPacketFollows = null; // avoid re-checking |
| } |
| } |
| |
| handleKexMessage(cmd, buffer); |
| } else if (currentService.process(cmd, buffer)) { |
| resetIdleTimeout(); |
| } else { |
| /* |
| * According to https://tools.ietf.org/html/rfc4253#section-11.4 |
| * |
| * An implementation MUST respond to all unrecognized messages with an SSH_MSG_UNIMPLEMENTED message |
| * in the order in which the messages were received. |
| */ |
| if (log.isDebugEnabled()) { |
| log.debug("process({}) Unsupported command: {}", |
| this, SshConstants.getCommandMessageName(cmd)); |
| } |
| notImplemented(cmd, buffer); |
| } |
| break; |
| } |
| checkRekey(); |
| } |
| |
| protected boolean handleFirstKexPacketFollows(int cmd, Buffer buffer, boolean followFlag) { |
| if (!followFlag) { |
| return true; // if 1st KEX packet does not follow then process the command |
| } |
| |
| /* |
| * According to RFC4253 section 7.1: |
| * |
| * If the other party's guess was wrong, and this field was TRUE, the next packet MUST be silently ignored |
| */ |
| boolean debugEnabled = log.isDebugEnabled(); |
| for (KexProposalOption option : KexProposalOption.FIRST_KEX_PACKET_GUESS_MATCHES) { |
| Map.Entry<String, String> result = comparePreferredKexProposalOption(option); |
| if (result != null) { |
| if (debugEnabled) { |
| log.debug( |
| "handleFirstKexPacketFollows({})[{}] 1st follow KEX packet {} option mismatch: client={}, server={}", |
| this, SshConstants.getCommandMessageName(cmd), option, result.getKey(), result.getValue()); |
| } |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| /** |
| * Compares the specified {@link KexProposalOption} option value for client vs. server |
| * |
| * @param option The option to check |
| * @return {@code null} if option is equal, otherwise a key/value pair where key=client option value and |
| * value=the server-side one |
| */ |
| protected Map.Entry<String, String> comparePreferredKexProposalOption(KexProposalOption option) { |
| String[] clientPreferences = GenericUtils.split(clientProposal.get(option), ','); |
| String clientValue = GenericUtils.isEmpty(clientPreferences) ? null : clientPreferences[0]; |
| String[] serverPreferences = GenericUtils.split(serverProposal.get(option), ','); |
| String serverValue = GenericUtils.isEmpty(serverPreferences) ? null : serverPreferences[0]; |
| if (GenericUtils.isEmpty(clientValue) || GenericUtils.isEmpty(serverValue) |
| || (!Objects.equals(clientValue, serverValue))) { |
| return new SimpleImmutableEntry<>(clientValue, serverValue); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Send a message to put new keys into use. |
| * |
| * @return An {@link IoWriteFuture} that can be used to wait and check the result of sending the packet |
| * @throws Exception if an error occurs sending the message |
| */ |
| protected IoWriteFuture sendNewKeys() throws Exception { |
| if (log.isDebugEnabled()) { |
| log.debug("sendNewKeys({}) Send SSH_MSG_NEWKEYS", this); |
| } |
| |
| prepareNewKeys(); |
| Buffer buffer = createBuffer(SshConstants.SSH_MSG_NEWKEYS, Byte.SIZE); |
| IoWriteFuture future; |
| synchronized (encodeLock) { |
| // writePacket() would also work since it would never try to queue the packet, and would never try to |
| // initiate a new KEX, and thus would never try to get the kexLock monitor. If it did, we might get a |
| // deadlock due to lock inversion. It seems safer to push this out directly, though. |
| future = doWritePacket(buffer); |
| // Use the new settings from now on for any outgoing packet |
| setOutputEncoding(); |
| } |
| kexHandler.updateState(() -> kexState.set(KexState.KEYS)); |
| |
| resetIdleTimeout(); |
| /* |
| * According to https://tools.ietf.org/html/rfc8308#section-2.4: |
| * |
| * |
| * If a client sends SSH_MSG_EXT_INFO, it MUST send it as the next packet following the client's first |
| * SSH_MSG_NEWKEYS message to the server. |
| * |
| * If a server sends SSH_MSG_EXT_INFO, it MAY send it at zero, one, or both of the following opportunities: |
| * |
| * + As the next packet following the server's first SSH_MSG_NEWKEYS. |
| */ |
| KexExtensionHandler extHandler = getKexExtensionHandler(); |
| if ((extHandler != null) && extHandler.isKexExtensionsAvailable(this, AvailabilityPhase.NEWKEYS)) { |
| extHandler.sendKexExtensions(this, KexPhase.NEWKEYS); |
| } |
| |
| SimpleImmutableEntry<Integer, DefaultKeyExchangeFuture> flushDone = kexHandler.terminateKeyExchange(); |
| |
| // Flush the queue asynchronously. |
| int numPending = flushDone.getKey().intValue(); |
| if (numPending == 0) { |
| if (log.isDebugEnabled()) { |
| log.debug("handleNewKeys({}) No pending packets to flush at end of KEX", this); |
| } |
| flushDone.getValue().setValue(Boolean.TRUE); |
| } else { |
| if (log.isDebugEnabled()) { |
| log.debug("handleNewKeys({}) {} pending packets to flush at end of KEX", this, numPending); |
| } |
| kexHandler.flushQueue(flushDone.getValue()); |
| } |
| |
| return future; |
| } |
| |
| protected void handleKexMessage(int cmd, Buffer buffer) throws Exception { |
| validateKexState(cmd, KexState.RUN); |
| |
| boolean debugEnabled = log.isDebugEnabled(); |
| if (kex.next(cmd, buffer)) { |
| if (debugEnabled) { |
| log.debug("handleKexMessage({})[{}] KEX processing complete after cmd={}", |
| this, kex.getName(), cmd); |
| } |
| checkKeys(); |
| sendNewKeys(); |
| } else { |
| if (debugEnabled) { |
| log.debug("handleKexMessage({})[{}] more KEX packets expected after cmd={}", |
| this, kex.getName(), cmd); |
| } |
| } |
| } |
| |
| protected void handleKexExtension(int cmd, Buffer buffer) throws Exception { |
| KexExtensionHandler extHandler = getKexExtensionHandler(); |
| int startPos = buffer.rpos(); |
| if ((extHandler != null) && extHandler.handleKexExtensionsMessage(this, buffer)) { |
| return; |
| } |
| |
| buffer.rpos(startPos); // restore original read position |
| notImplemented(cmd, buffer); |
| } |
| |
| protected void handleNewCompression(int cmd, Buffer buffer) throws Exception { |
| KexExtensionHandler extHandler = getKexExtensionHandler(); |
| int startPos = buffer.rpos(); |
| if ((extHandler != null) && extHandler.handleKexCompressionMessage(this, buffer)) { |
| return; |
| } |
| |
| buffer.rpos(startPos); // restore original read position |
| notImplemented(cmd, buffer); |
| } |
| |
| protected void handleServiceRequest(Buffer buffer) throws Exception { |
| String serviceName = buffer.getString(); |
| handleServiceRequest(serviceName, buffer); |
| } |
| |
| protected boolean handleServiceRequest(String serviceName, Buffer buffer) throws Exception { |
| boolean debugEnabled = log.isDebugEnabled(); |
| if (debugEnabled) { |
| log.debug("handleServiceRequest({}) SSH_MSG_SERVICE_REQUEST '{}'", this, serviceName); |
| } |
| KexState state = kexState.get(); |
| if (!validateServiceKexState(state)) { |
| throw new IllegalStateException( |
| "Received " + SshConstants.getCommandMessageName(SshConstants.SSH_MSG_SERVICE_REQUEST) |
| + " while in KEX state=" + state); |
| } |
| |
| try { |
| startService(serviceName, buffer); |
| } catch (Throwable e) { |
| debug("handleServiceRequest({}) Service {} rejected: {} = {}", |
| this, serviceName, e.getClass().getSimpleName(), e.getMessage(), e); |
| disconnect(SshConstants.SSH2_DISCONNECT_SERVICE_NOT_AVAILABLE, "Bad service request: " + serviceName); |
| return false; |
| } |
| |
| if (debugEnabled) { |
| log.debug("handleServiceRequest({}) Accepted service {}", this, serviceName); |
| } |
| |
| Buffer response = createBuffer( |
| SshConstants.SSH_MSG_SERVICE_ACCEPT, Byte.SIZE + GenericUtils.length(serviceName)); |
| response.putString(serviceName); |
| writePacket(response); |
| return true; |
| } |
| |
| protected boolean validateServiceKexState(KexState state) { |
| if (KexState.DONE.equals(state)) { |
| return true; |
| } else if (KexState.INIT.equals(state)) { |
| // Allow service requests that were "in flight" when we sent our own KEX_INIT. We will send back the accept |
| // only after KEX is done. However, we will refuse a service request before the initial KEX. |
| return initialKexDone; |
| } |
| return false; |
| } |
| |
| protected void handleServiceAccept(Buffer buffer) throws Exception { |
| handleServiceAccept(buffer.getString(), buffer); |
| } |
| |
| protected void handleServiceAccept(String serviceName, Buffer buffer) throws Exception { |
| if (log.isDebugEnabled()) { |
| log.debug("handleServiceAccept({}) SSH_MSG_SERVICE_ACCEPT service={}", this, serviceName); |
| } |
| KexState state = kexState.get(); |
| if (!validateServiceKexState(state)) { |
| throw new IllegalStateException( |
| "Received " + SshConstants.getCommandMessageName(SshConstants.SSH_MSG_SERVICE_REQUEST) |
| + " while in KEX state=" + state); |
| } |
| } |
| |
| protected void handleKexInit(Buffer buffer) throws Exception { |
| if (log.isDebugEnabled()) { |
| log.debug("handleKexInit({}) SSH_MSG_KEXINIT", this); |
| } |
| receiveKexInit(buffer); |
| doKexNegotiation(); |
| } |
| |
| private enum KexStart { |
| PEER, |
| BOTH, |
| ONGOING |
| } |
| |
| protected void doKexNegotiation() throws Exception { |
| KexStart starting = kexHandler.updateState(() -> { |
| if (kexState.compareAndSet(KexState.DONE, KexState.RUN)) { |
| kexHandler.initNewKeyExchange(); |
| return KexStart.PEER; |
| } else if (kexState.compareAndSet(KexState.INIT, KexState.RUN)) { |
| return KexStart.BOTH; |
| } |
| return KexStart.ONGOING; |
| }); |
| |
| switch (starting) { |
| case PEER: |
| sendKexInit(); |
| break; |
| case BOTH: |
| DefaultKeyExchangeFuture initFuture; |
| synchronized (kexState) { |
| initFuture = kexInitializedFuture; |
| if (initFuture == null) { |
| initFuture = new DefaultKeyExchangeFuture(toString(), null); |
| kexInitializedFuture = initFuture; |
| } |
| } |
| // requestNewKeyExchange() is running in some other thread: wait until it has set up our own proposal. |
| // The timeout is a last resort only to avoid blocking indefinitely in case something goes |
| // catastrophically wrong somewhere; it should never be hit. If it is, an exception will be thrown. |
| // |
| // See https://issues.apache.org/jira/browse/SSHD-1197 |
| initFuture.await(CoreModuleProperties.KEX_PROPOSAL_SETUP_TIMEOUT.getRequired(this)); |
| break; |
| default: |
| throw new IllegalStateException("Received SSH_MSG_KEXINIT while key exchange is running"); |
| } |
| |
| Map<KexProposalOption, String> result = negotiate(); |
| String kexAlgorithm = result.get(KexProposalOption.ALGORITHMS); |
| Collection<? extends KeyExchangeFactory> kexFactories = getKeyExchangeFactories(); |
| KeyExchangeFactory kexFactory = NamedResource.findByName( |
| kexAlgorithm, String.CASE_INSENSITIVE_ORDER, kexFactories); |
| ValidateUtils.checkNotNull(kexFactory, "Unknown negotiated KEX algorithm: %s", kexAlgorithm); |
| |
| byte[] v_s = serverVersion.getBytes(StandardCharsets.UTF_8); |
| byte[] v_c = clientVersion.getBytes(StandardCharsets.UTF_8); |
| byte[] i_s; |
| byte[] i_c; |
| synchronized (kexState) { |
| i_s = getServerKexData(); |
| i_c = getClientKexData(); |
| } |
| |
| kex = kexFactory.createKeyExchange(this); |
| kex.init(v_s, v_c, i_s, i_c); |
| |
| synchronized (kexState) { |
| kexInitializedFuture = null; |
| } |
| signalSessionEvent(SessionListener.Event.KexCompleted); |
| } |
| |
| protected void handleNewKeys(int cmd, Buffer buffer) throws Exception { |
| boolean debugEnabled = log.isDebugEnabled(); |
| if (debugEnabled) { |
| log.debug("handleNewKeys({}) SSH_MSG_NEWKEYS command={}", |
| this, SshConstants.getCommandMessageName(cmd)); |
| } |
| validateKexState(cmd, KexState.KEYS); |
| // It is guaranteed that we handle the peer's SSH_MSG_NEWKEYS after having sent our own. |
| // prepareNewKeys() was already called in sendNewKeys(). |
| // |
| // From now on, use the new settings for any incoming message. |
| setInputEncoding(); |
| |
| synchronized (kexState) { |
| kexInitializedFuture = null; |
| } |
| |
| initialKexDone = true; |
| DefaultKeyExchangeFuture kexFuture = kexFutureHolder.get(); |
| if (kexFuture != null) { |
| kexFuture.setValue(Boolean.TRUE); |
| } |
| |
| signalSessionEvent(SessionListener.Event.KeyEstablished); |
| |
| kexHandler.updateState(() -> { |
| kex = null; // discard and GC since KEX is completed |
| kexState.set(KexState.DONE); |
| }); |
| |
| synchronized (futureLock) { |
| futureLock.notifyAll(); |
| } |
| } |
| |
| protected void validateKexState(int cmd, KexState expected) { |
| KexState actual = kexState.get(); |
| if (!expected.equals(actual)) { |
| throw new IllegalStateException("Received KEX command=" + SshConstants.getCommandMessageName(cmd) |
| + " while in state=" + actual + " instead of " + expected); |
| } |
| } |
| |
| @Override |
| protected Closeable getInnerCloseable() { |
| Closeable closer = builder() |
| .parallel(toString(), getServices()) |
| .close(getIoSession()) |
| .build(); |
| closer.addCloseFutureListener(future -> clearAttributes()); |
| return closer; |
| } |
| |
| @Override |
| protected void preClose() { |
| DefaultKeyExchangeFuture initFuture; |
| synchronized (kexState) { |
| initFuture = kexInitializedFuture; |
| } |
| if (initFuture != null) { |
| initFuture.setValue(new SshException("Session closing while KEX in progress")); |
| } |
| DefaultKeyExchangeFuture kexFuture = kexFutureHolder.get(); |
| if (kexFuture != null) { |
| // if have any pending KEX then notify it about the closing session |
| kexFuture.setValue(new SshException("Session closing while KEX in progress")); |
| } |
| kexHandler.shutdown(); |
| |
| // if anyone waiting for global response notify them about the closing session |
| boolean debugEnabled = log.isDebugEnabled(); |
| for (;;) { |
| GlobalRequestFuture future = pendingGlobalRequests.pollLast(); |
| if (future == null) { |
| break; |
| } |
| if (debugEnabled) { |
| log.debug("preClose({}): Session closing; failing still pending global request {}", this, future.getId()); |
| } |
| future.setValue(new SshException("Session is closing")); |
| } |
| |
| // Fire 'close' event |
| try { |
| signalSessionClosed(); |
| } finally { |
| // clear the listeners since we are closing the session (quicker GC) |
| this.sessionListeners.clear(); |
| this.channelListeners.clear(); |
| this.tunnelListeners.clear(); |
| } |
| |
| super.preClose(); |
| } |
| |
| protected List<Service> getServices() { |
| Service service = currentService.getService(); |
| return (service != null) |
| ? Collections.singletonList(service) |
| : Collections.emptyList(); |
| } |
| |
| @Override |
| public <T extends Service> T getService(Class<T> clazz) { |
| Collection<? extends Service> registeredServices = getServices(); |
| ValidateUtils.checkState(GenericUtils.isNotEmpty(registeredServices), |
| "No registered services to look for %s", clazz.getSimpleName()); |
| |
| for (Service s : registeredServices) { |
| if (clazz.isInstance(s)) { |
| return clazz.cast(s); |
| } |
| } |
| |
| throw new IllegalStateException("Attempted to access unknown service " + clazz.getSimpleName()); |
| } |
| |
| @Override |
| protected Buffer preProcessEncodeBuffer(int cmd, Buffer buffer) throws IOException { |
| buffer = super.preProcessEncodeBuffer(cmd, buffer); |
| // SSHD-968 - remember global request outgoing sequence number |
| LongConsumer setter = globalSequenceNumbers.remove(buffer); |
| if (setter != null) { |
| setter.accept(seqo); |
| } |
| return buffer; |
| } |
| |
| @Override |
| public IoWriteFuture writePacket(Buffer buffer) throws IOException { |
| return kexHandler.writePacket(buffer, 0, null); |
| } |
| |
| @Override |
| public IoWriteFuture writePacket(Buffer buffer, long timeout, TimeUnit unit) throws IOException { |
| long timeoutMillis = unit.toMillis(timeout); |
| IoWriteFuture writeFuture; |
| try { |
| long start = System.currentTimeMillis(); |
| writeFuture = kexHandler.writePacket(buffer, timeout, unit); |
| long elapsed = System.currentTimeMillis() - start; |
| if (elapsed >= timeoutMillis) { |
| // We just barely made it. Give it a tiny grace period. |
| timeoutMillis = 1; |
| } else { |
| timeoutMillis -= elapsed; |
| } |
| } catch (InterruptedIOException e) { |
| // Already timed out |
| PendingWriteFuture timedOut = new PendingWriteFuture(this, buffer); |
| Throwable t = new TimeoutException("Timeout writing packet: " + timeout + " " + unit); |
| t.initCause(e); |
| if (log.isDebugEnabled()) { |
| log.debug("writePacket({}): {}", AbstractSession.this, t.getMessage()); |
| } |
| timedOut.setValue(t); |
| return timedOut; |
| } |
| if (writeFuture.isDone()) { |
| // No need to schedule anything. |
| return writeFuture; |
| } |
| @SuppressWarnings("unchecked") |
| DefaultSshFuture<IoWriteFuture> future = (DefaultSshFuture<IoWriteFuture>) writeFuture; |
| FactoryManager factoryManager = getFactoryManager(); |
| ScheduledExecutorService executor = factoryManager.getScheduledExecutorService(); |
| ScheduledFuture<?> sched = executor.schedule(() -> { |
| Throwable t = new TimeoutException("Timeout writing packet: " + timeout + " " + unit); |
| if (log.isDebugEnabled()) { |
| log.debug("writePacket({}): {}", AbstractSession.this, t.getMessage()); |
| } |
| future.setValue(t); |
| }, timeoutMillis, TimeUnit.MILLISECONDS); |
| future.addListener(f -> sched.cancel(false)); |
| return writeFuture; |
| } |
| |
| // NOTE: must acquire encodeLock when calling this method |
| protected Buffer resolveOutputPacket(Buffer buffer) throws IOException { |
| Buffer ignoreBuf = null; |
| int ignoreDataLen = resolveIgnoreBufferDataLength(); |
| if (ignoreDataLen > 0) { |
| ignoreBuf = createBuffer(SshConstants.SSH_MSG_IGNORE, ignoreDataLen + Byte.SIZE); |
| ignoreBuf.putUInt(ignoreDataLen); |
| |
| int wpos = ignoreBuf.wpos(); |
| synchronized (random) { |
| random.fill(ignoreBuf.array(), wpos, ignoreDataLen); |
| } |
| ignoreBuf.wpos(wpos + ignoreDataLen); |
| |
| if (log.isDebugEnabled()) { |
| log.debug("resolveOutputPacket({}) append SSH_MSG_IGNORE message", this); |
| } |
| } |
| |
| int curPos = buffer.rpos(); |
| byte[] data = buffer.array(); |
| int cmd = data[curPos] & 0xFF; // usually the 1st byte is the command |
| buffer = validateTargetBuffer(cmd, buffer); |
| |
| if (ignoreBuf != null) { |
| ignoreBuf = encode(ignoreBuf); |
| |
| IoSession networkSession = getIoSession(); |
| networkSession.writeBuffer(ignoreBuf); |
| } |
| |
| return encode(buffer); |
| } |
| |
| protected IoWriteFuture doWritePacket(Buffer buffer) throws IOException { |
| // Synchronize all write requests as needed by the encoding algorithm |
| // and also queue the write request in this synchronized block to ensure |
| // packets are sent in the correct order |
| synchronized (encodeLock) { |
| Buffer packet = resolveOutputPacket(buffer); |
| IoSession networkSession = getIoSession(); |
| IoWriteFuture future = networkSession.writeBuffer(packet); |
| return future; |
| } |
| } |
| |
| protected int resolveIgnoreBufferDataLength() { |
| if ((ignorePacketDataLength <= 0) |
| || (ignorePacketsFrequency <= 0L) |
| || (ignorePacketsVariance < 0)) { |
| return 0; |
| } |
| |
| long count = ignorePacketsCount.decrementAndGet(); |
| if (count > 0L) { |
| return 0; |
| } |
| |
| synchronized (random) { |
| count = calculateNextIgnorePacketCount( |
| random, ignorePacketsFrequency, ignorePacketsVariance); |
| ignorePacketsCount.set(count); |
| return ignorePacketDataLength + random.random(ignorePacketDataLength); |
| } |
| } |
| |
| private boolean wantReply(Buffer buffer) { |
| // Examine the buffer to get the want-reply flag |
| int rpos = buffer.rpos(); |
| buffer.getByte(); // Skip command |
| buffer.getString(); // Skip request name |
| boolean replyFlag = buffer.getBoolean(); |
| buffer.rpos(rpos); // reset buffer |
| return replyFlag; |
| } |
| |
| @Override |
| public Buffer request(String request, Buffer buffer, long maxWaitMillis) throws IOException { |
| ValidateUtils.checkTrue(maxWaitMillis > 0, |
| "Requested timeout for " + request + " is not strictly greater than zero: " + maxWaitMillis); |
| boolean debugEnabled = log.isDebugEnabled(); |
| boolean withReply = wantReply(buffer); |
| GlobalRequestFuture future = request(buffer, request, null); |
| Object result; |
| boolean done = false; |
| try { |
| if (debugEnabled) { |
| log.debug("request({}) request={}, timeout={}ms", this, request, maxWaitMillis); |
| } |
| done = future.await(maxWaitMillis); |
| result = future.getValue(); |
| } catch (InterruptedIOException e) { |
| throw (InterruptedIOException) new InterruptedIOException( |
| "Interrupted while waiting for request=" + request + " result").initCause(e); |
| } |
| |
| if (!isOpen()) { |
| throw new IOException("Session was closed or closing while awaiting reply for request=" + request); |
| } |
| |
| if (withReply) { |
| if (debugEnabled) { |
| log.debug("request({}) request={}, timeout={}ms, requestSeqNo={}, done {}, result received={}", this, request, |
| maxWaitMillis, future.getSequenceNumber(), done, result instanceof Buffer); |
| } |
| |
| if (!done || result == null) { |
| throw new SocketTimeoutException("No response received after " + maxWaitMillis + "ms for request=" + request); |
| } |
| // The operation is specified to return null if the request could be made, but got an error reply. |
| // The caller cannot distinguish between SSH_MSG_UNIMPLEMENTED and SSH_MSG_REQUEST_FAILURE. |
| if (result instanceof GlobalRequestException) { |
| if (debugEnabled) { |
| log.debug("request({}) request={}, requestSeqNo={}: received={}", this, request, future.getSequenceNumber(), |
| SshConstants.getCommandMessageName(((GlobalRequestException) result).getCode())); |
| } |
| return null; |
| } |
| } |
| |
| if (result instanceof Throwable) { |
| throw new IOException("Exception on request " + request, (Throwable) result); |
| } |
| if (result instanceof Buffer) { |
| return (Buffer) result; |
| } |
| return null; |
| } |
| |
| @Override |
| public GlobalRequestFuture request(Buffer buffer, String request, GlobalRequestFuture.ReplyHandler replyHandler) |
| throws IOException { |
| GlobalRequestFuture globalRequest; |
| if (!wantReply(buffer)) { |
| if (!isOpen()) { |
| throw new IOException("Global request " + request + ": session is closing or closed."); |
| } |
| // Fire-and-forget global requests (want-reply = false) are always allowed; we don't need to register the |
| // future, nor do we have to wait for anything. Client code can wait on the returned future if it wants to |
| // be sure the message has been sent. |
| globalRequest = new GlobalRequestFuture(request, replyHandler) { |
| |
| @Override |
| public void operationComplete(IoWriteFuture future) { |
| if (future.isWritten()) { |
| if (log.isDebugEnabled()) { |
| log.debug("makeGlobalRequest({})[{}] want-reply=false sent", this, getId()); |
| } |
| setValue(new ByteArrayBuffer(new byte[0])); |
| GlobalRequestFuture.ReplyHandler handler = getHandler(); |
| if (handler != null) { |
| handler.accept(SshConstants.SSH_MSG_REQUEST_SUCCESS, getBuffer()); |
| } |
| } |
| super.operationComplete(future); |
| } |
| }; |
| writePacket(buffer).addListener(globalRequest); |
| return globalRequest; |
| } |
| // We do expect a reply. The packet may get queued or otherwise delayed for an unknown time. We must |
| // consider this request pending only once its sequence number is known. If sending the message fails, |
| // the writeFuture will set an exception on the globalRequest, or will fail it. |
| globalRequest = new GlobalRequestFuture(request, replyHandler) { |
| |
| @Override |
| public void operationComplete(IoWriteFuture future) { |
| if (!future.isWritten()) { |
| // If it was not written after all, make sure it's not considered pending anymore. |
| pendingGlobalRequests.removeFirstOccurrence(this); |
| } |
| // Super call will fulfill the future if not written |
| super.operationComplete(future); |
| if (future.isWritten() && getHandler() != null) { |
| // Fulfill this future now. The GlobalRequestFuture can thus be used to wait for the |
| // successful sending of the request, the framework will invoke the handler whenever |
| // the reply arrives. The buffer cannot be obtained though the future. |
| setValue(null); |
| } |
| } |
| }; |
| if (!isOpen()) { |
| throw new IOException("Global request " + request + ": session is closing or closed."); |
| } |
| // This consumer will be invoked once before the packet actually goes out. Some servers respond to global |
| // requests with SSH_MSG_UNIMPLEMENTED instead of SSH_MSG_REQUEST_FAILURE (see SSHD-968), so we need to make |
| // sure we do know the sequence number. |
| globalSequenceNumbers.put(buffer, seqNo -> { |
| globalRequest.setSequenceNumber(seqNo); |
| if (log.isDebugEnabled()) { |
| log.debug("makeGlobalRequest({})[{}] want-reply=true with seqNo={}", this, globalRequest.getId(), seqNo); |
| } |
| // Insert at front |
| pendingGlobalRequests.push(globalRequest); |
| }); |
| writePacket(buffer).addListener(f -> { |
| Throwable t = f.getException(); |
| if (t != null) { |
| // Just in case we get an exception before preProcessEncodeBuffer was even called |
| globalSequenceNumbers.remove(buffer); |
| } |
| }).addListener(globalRequest); // Report errors through globalRequest, fulfilling globalRequest |
| return globalRequest; |
| } |
| |
| @Override |
| protected boolean doInvokeUnimplementedMessageHandler(int cmd, Buffer buffer) throws Exception { |
| /* |
| * SSHD-968 Some servers respond to global requests with SSH_MSG_UNIMPLEMENTED instead of |
| * SSH_MSG_REQUEST_FAILURE (as mandated by https://tools.ietf.org/html/rfc4254#section-4) so deal with it. |
| */ |
| if (!pendingGlobalRequests.isEmpty() && cmd == SshConstants.SSH_MSG_UNIMPLEMENTED) { |
| // We do have ongoing global requests. |
| long msgSeqNo = buffer.rawUInt(buffer.rpos()); |
| |
| // Find the global request this applies to |
| GlobalRequestFuture future = pendingGlobalRequests.stream().filter(f -> f.getSequenceNumber() == msgSeqNo).findAny() |
| .orElse(null); |
| if (future != null && pendingGlobalRequests.removeFirstOccurrence(future)) { |
| // This SSH_MSG_UNIMPLEMENTED was the reply to a global request. |
| if (log.isDebugEnabled()) { |
| log.debug("doInvokeUnimplementedMessageHandler({}) report global request={} failure for seqNo={}", this, |
| future.getId(), msgSeqNo); |
| } |
| GlobalRequestFuture.ReplyHandler handler = future.getHandler(); |
| if (handler != null) { |
| Buffer resultBuf = ByteArrayBuffer.getCompactClone(buffer.array(), buffer.rpos(), buffer.available()); |
| handler.accept(cmd, resultBuf); |
| } else { |
| future.setValue(new GlobalRequestException(cmd)); |
| } |
| return true; // message handled internally |
| } else if (future != null) { |
| // The SSH_MSG_UNIMPLEMENTED was for a global request, but that request is no longer in the list: it |
| // got terminated otherwise. |
| return true; |
| } |
| if (log.isTraceEnabled()) { |
| log.trace( |
| "doInvokeUnimplementedMessageHandler({}) SSH_MSG_UNIMPLEMENTED with message seqNo={} not for a global request", |
| this, msgSeqNo); |
| } |
| } |
| |
| return super.doInvokeUnimplementedMessageHandler(cmd, buffer); |
| } |
| |
| @Override |
| public Buffer createBuffer(byte cmd, int len) { |
| if (len <= 0) { |
| return prepareBuffer(cmd, new ByteArrayBuffer()); |
| } |
| |
| // Since the caller claims to know how many bytes they will need |
| // increase their request to account for our headers/footers if |
| // they actually send exactly this amount. |
| boolean etmMode = outMac != null && outMac.isEncryptThenMac(); |
| int authLen = outCipher != null ? outCipher.getAuthenticationTagSize() : 0; |
| boolean authMode = authLen > 0; |
| int pad = calculatePadLength(len, outCipherSize, etmMode || authMode); |
| len += SshConstants.SSH_PACKET_HEADER_LEN + pad + authLen; |
| if (outMac != null) { |
| len += outMacSize; |
| } |
| |
| return prepareBuffer(cmd, new ByteArrayBuffer(new byte[len + Byte.SIZE], false)); |
| } |
| |
| @Override |
| public Buffer prepareBuffer(byte cmd, Buffer buffer) { |
| buffer = validateTargetBuffer(cmd & 0xFF, buffer); |
| buffer.rpos(SshConstants.SSH_PACKET_HEADER_LEN); |
| buffer.wpos(SshConstants.SSH_PACKET_HEADER_LEN); |
| buffer.putByte(cmd); |
| return buffer; |
| } |
| |
| /** |
| * Makes sure that the buffer used for output is not {@code null} or one of the session's internal ones used for |
| * decoding and uncompressing |
| * |
| * @param <B> The {@link Buffer} type being validated |
| * @param cmd The most likely command this buffer refers to (not guaranteed to be correct) |
| * @param buffer The buffer to be examined |
| * @return The validated target instance - default same as input |
| * @throws IllegalArgumentException if any of the conditions is violated |
| */ |
| protected <B extends Buffer> B validateTargetBuffer(int cmd, B buffer) { |
| ValidateUtils.checkNotNull(buffer, "No target buffer to examine for command=%d", cmd); |
| ValidateUtils.checkTrue( |
| buffer != decoderBuffer, "Not allowed to use the internal decoder buffer for command=%d", cmd); |
| ValidateUtils.checkTrue( |
| buffer != uncompressBuffer, "Not allowed to use the internal uncompress buffer for command=%d", cmd); |
| return buffer; |
| } |
| |
| /** |
| * Encode a buffer into the SSH protocol. <B>Note:</B> This method must be called inside a {@code synchronized} |
| * block using {@code encodeLock}. |
| * |
| * @param buffer the buffer to encode |
| * @return The encoded buffer - may be different than original if input buffer does not have enough room |
| * for {@link SshConstants#SSH_PACKET_HEADER_LEN}, in which case a substitute buffer will be |
| * created and used. |
| * @throws IOException if an exception occurs during the encoding process |
| */ |
| protected Buffer encode(Buffer buffer) throws IOException { |
| try { |
| // Check that the packet has some free space for the header |
| int curPos = buffer.rpos(); |
| int cmd = buffer.rawByte(curPos) & 0xFF; // usually the 1st byte is an SSH opcode |
| Buffer nb = preProcessEncodeBuffer(cmd, buffer); |
| if (nb != buffer) { |
| buffer = nb; |
| curPos = buffer.rpos(); |
| |
| int newCmd = buffer.rawByte(curPos) & 0xFF; |
| if (cmd != newCmd) { |
| log.warn("encode({}) - command changed from {}[{}] to {}[{}] by pre-processor", |
| this, cmd, SshConstants.getCommandMessageName(cmd), |
| newCmd, SshConstants.getCommandMessageName(newCmd)); |
| cmd = newCmd; |
| } |
| } |
| |
| // Grab the length of the packet (excluding the 5 header bytes) |
| int len = buffer.available(); |
| if (log.isDebugEnabled()) { |
| log.debug("encode({}) packet #{} sending command={}[{}] len={}", |
| this, seqo, cmd, SshConstants.getCommandMessageName(cmd), len); |
| } |
| |
| int off = curPos - SshConstants.SSH_PACKET_HEADER_LEN; |
| // Debug log the packet |
| boolean traceEnabled = log.isTraceEnabled(); |
| if (traceEnabled) { |
| buffer.dumpHex(getSimplifiedLogger(), Level.FINEST, |
| "encode(" + this + ") packet #" + seqo, this); |
| } |
| |
| // Compress the packet if needed |
| if ((outCompression != null) |
| && outCompression.isCompressionExecuted() |
| && (isAuthenticated() || (!outCompression.isDelayed()))) { |
| int oldLen = len; |
| outCompression.compress(buffer); |
| len = buffer.available(); |
| if (traceEnabled) { |
| log.trace("encode({}) packet #{} command={}[{}] compressed {} -> {}", |
| this, seqo, cmd, SshConstants.getCommandMessageName(cmd), oldLen, len); |
| } |
| } |
| |
| // Compute padding length |
| boolean etmMode = outMac != null && outMac.isEncryptThenMac(); |
| int authSize = outCipher != null ? outCipher.getAuthenticationTagSize() : 0; |
| boolean authMode = authSize > 0; |
| int oldLen = len; |
| |
| int pad = calculatePadLength(len, outCipherSize, etmMode || authMode); |
| |
| len += Byte.BYTES + pad; |
| |
| if (traceEnabled) { |
| log.trace("encode({}) packet #{} command={}[{}] len={}, pad={}, mac={}", |
| this, seqo, cmd, SshConstants.getCommandMessageName(cmd), len, pad, outMac); |
| } |
| |
| // Write 5 header bytes |
| buffer.wpos(off); |
| buffer.putUInt(len); |
| buffer.putByte((byte) pad); |
| // Make sure enough room for padding and then fill it |
| buffer.wpos(off + oldLen + SshConstants.SSH_PACKET_HEADER_LEN + pad); |
| synchronized (random) { |
| random.fill(buffer.array(), buffer.wpos() - pad, pad); |
| } |
| |
| if (authMode) { |
| int wpos = buffer.wpos(); |
| buffer.wpos(wpos + authSize); |
| aeadOutgoingBuffer(buffer, off, len); |
| } else if (etmMode) { |
| // Do not encrypt the length field |
| encryptOutgoingBuffer(buffer, off + Integer.BYTES, len); |
| appendOutgoingMac(buffer, off, len); |
| } else { |
| appendOutgoingMac(buffer, off, len); |
| encryptOutgoingBuffer(buffer, off, len + Integer.BYTES); |
| } |
| |
| // Increment packet id |
| seqo = (seqo + 1L) & 0x0ffffffffL; |
| |
| // Update counters used to track re-keying |
| outPacketsCount.incrementAndGet(); |
| outBytesCount.addAndGet(len); |
| |
| // Make buffer ready to be read |
| buffer.rpos(off); |
| return buffer; |
| } catch (IOException e) { |
| throw e; |
| } catch (Exception e) { |
| throw new SshException(e); |
| } |
| } |
| |
| protected void aeadOutgoingBuffer(Buffer buf, int offset, int len) throws Exception { |
| if (outCipher == null || outCipher.getAuthenticationTagSize() == 0) { |
| throw new IllegalArgumentException("AEAD mode requires an AEAD cipher"); |
| } |
| byte[] data = buf.array(); |
| outCipher.updateWithAAD(data, offset, Integer.BYTES, len); |
| int blocksCount = len / outCipherSize; |
| outBlocksCount.addAndGet(Math.max(1, blocksCount)); |
| } |
| |
| protected void appendOutgoingMac(Buffer buf, int offset, int len) throws Exception { |
| if (outMac == null) { |
| return; |
| } |
| |
| int l = buf.wpos(); |
| // ensure enough room for MAC in outgoing buffer |
| buf.wpos(l + outMacSize); |
| // Include sequence number |
| outMac.updateUInt(seqo); |
| // Include the length field in the MAC calculation |
| outMac.update(buf.array(), offset, len + Integer.BYTES); |
| // Append MAC to end of packet |
| outMac.doFinal(buf.array(), l); |
| } |
| |
| protected void encryptOutgoingBuffer(Buffer buf, int offset, int len) throws Exception { |
| if (outCipher == null) { |
| return; |
| } |
| outCipher.update(buf.array(), offset, len); |
| |
| int blocksCount = len / outCipherSize; |
| outBlocksCount.addAndGet(Math.max(1, blocksCount)); |
| } |
| |
| /** |
| * Decode the incoming buffer and handle packets as needed. |
| * |
| * @throws Exception If failed to decode |
| */ |
| protected void decode() throws Exception { |
| // Decoding loop |
| for (;;) { |
| |
| int authSize = inCipher != null ? inCipher.getAuthenticationTagSize() : 0; |
| boolean authMode = authSize > 0; |
| int macSize = inMac != null ? inMacSize : 0; |
| boolean etmMode = inMac != null && inMac.isEncryptThenMac(); |
| // Wait for beginning of packet |
| if (decoderState == 0) { |
| // The read position should always be 0 at this point because we have compacted this buffer |
| assert decoderBuffer.rpos() == 0; |
| /* |
| * Note: according to RFC-4253 section 6: |
| * |
| * Implementations SHOULD decrypt the length after receiving the first 8 (or cipher block size whichever |
| * is larger) bytes |
| * |
| * However, we currently do not have ciphers with a block size of less than 8 we avoid un-necessary |
| * Math.max(minBufLen, 8) for each and every packet |
| */ |
| int minBufLen = etmMode || authMode ? Integer.BYTES : inCipherSize; |
| // If we have received enough bytes, start processing those |
| if (decoderBuffer.available() > minBufLen) { |
| if (authMode) { |
| // RFC 5647: packet length encoded in additional data |
| inCipher.updateAAD(decoderBuffer.array(), 0, Integer.BYTES); |
| } else if ((inCipher != null) && (!etmMode)) { |
| // Decrypt the first bytes so we can extract the packet length |
| inCipher.update(decoderBuffer.array(), 0, inCipherSize); |
| |
| int blocksCount = inCipherSize / inCipher.getCipherBlockSize(); |
| inBlocksCount.addAndGet(Math.max(1, blocksCount)); |
| } |
| // Read packet length |
| decoderLength = decoderBuffer.getInt(); |
| /* |
| * Check packet length validity - we allow 8 times the minimum required packet length support in |
| * order to be aligned with some OpenSSH versions that allow up to 256k |
| */ |
| if ((decoderLength < SshConstants.SSH_PACKET_HEADER_LEN) |
| || (decoderLength > (8 * SshConstants.SSH_REQUIRED_PAYLOAD_PACKET_LENGTH_SUPPORT))) { |
| log.warn("decode({}) Error decoding packet(invalid length): {}", this, decoderLength); |
| decoderBuffer.dumpHex(getSimplifiedLogger(), Level.FINEST, |
| "decode(" + this + ") invalid length packet", this); |
| throw new SshException(SshConstants.SSH2_DISCONNECT_PROTOCOL_ERROR, |
| "Invalid packet length: " + decoderLength); |
| } |
| // Ok, that's good, we can go to the next step |
| decoderState = 1; |
| } else { |
| // need more data |
| break; |
| } |
| // We have received the beginning of the packet |
| } else if (decoderState == 1) { |
| // The read position should always be after reading the packet length at this point |
| assert decoderBuffer.rpos() == Integer.BYTES; |
| // Check if the packet has been fully received |
| if (decoderBuffer.available() >= (decoderLength + macSize + authSize)) { |
| byte[] data = decoderBuffer.array(); |
| if (authMode) { |
| inCipher.update(data, Integer.BYTES /* packet length is handled by AAD */, decoderLength); |
| |
| int blocksCount = decoderLength / inCipherSize; |
| inBlocksCount.addAndGet(Math.max(1, blocksCount)); |
| } else if (etmMode) { |
| validateIncomingMac(data, 0, decoderLength + Integer.BYTES); |
| |
| if (inCipher != null) { |
| inCipher.update(data, Integer.BYTES /* packet length is unencrypted */, decoderLength); |
| |
| int blocksCount = decoderLength / inCipherSize; |
| inBlocksCount.addAndGet(Math.max(1, blocksCount)); |
| } |
| } else { |
| /* |
| * Decrypt the remaining of the packet - skip the block we already decoded in order to extract |
| * the packet length |
| */ |
| if (inCipher != null) { |
| int updateLen = decoderLength + Integer.BYTES - inCipherSize; |
| inCipher.update(data, inCipherSize, updateLen); |
| |
| int blocksCount = updateLen / inCipherSize; |
| inBlocksCount.addAndGet(Math.max(1, blocksCount)); |
| } |
| |
| validateIncomingMac(data, 0, decoderLength + Integer.BYTES); |
| } |
| |
| // Increment incoming packet sequence number |
| seqi = (seqi + 1L) & 0x0ffffffffL; |
| |
| // Get padding |
| int pad = decoderBuffer.getUByte(); |
| Buffer packet; |
| int wpos = decoderBuffer.wpos(); |
| // Decompress if needed |
| if ((inCompression != null) |
| && inCompression.isCompressionExecuted() |
| && (isAuthenticated() || (!inCompression.isDelayed()))) { |
| if (uncompressBuffer == null) { |
| uncompressBuffer = new SessionWorkBuffer(this); |
| } else { |
| uncompressBuffer.forceClear(true); |
| } |
| |
| decoderBuffer.wpos(decoderBuffer.rpos() + decoderLength - 1 - pad); |
| inCompression.uncompress(decoderBuffer, uncompressBuffer); |
| packet = uncompressBuffer; |
| } else { |
| decoderBuffer.wpos(decoderLength + Integer.BYTES - pad); |
| packet = decoderBuffer; |
| } |
| |
| if (log.isTraceEnabled()) { |
| packet.dumpHex(getSimplifiedLogger(), Level.FINEST, |
| "decode(" + this + ") packet #" + seqi, this); |
| } |
| |
| // Update counters used to track re-keying |
| inPacketsCount.incrementAndGet(); |
| inBytesCount.addAndGet(packet.available()); |
| |
| // Process decoded packet |
| handleMessage(packet); |
| |
| // Set ready to handle next packet |
| decoderBuffer.rpos(decoderLength + Integer.BYTES + macSize + authSize); |
| decoderBuffer.wpos(wpos); |
| decoderBuffer.compact(); |
| decoderState = 0; |
| } else { |
| // need more data |
| break; |
| } |
| } |
| } |
| } |
| |
| protected void validateIncomingMac(byte[] data, int offset, int len) throws Exception { |
| if (inMac == null) { |
| return; |
| } |
| |
| // Update mac with packet id |
| inMac.updateUInt(seqi); |
| // Update mac with packet data |
| inMac.update(data, offset, len); |
| // Compute mac result |
| inMac.doFinal(inMacResult, 0); |
| |
| // Check the computed result with the received mac (just after the packet data) |
| if (!Mac.equals(inMacResult, 0, data, offset + len, inMacSize)) { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_MAC_ERROR, "MAC Error"); |
| } |
| } |
| |
| /** |
| * Read the other side identification. This method is specific to the client or server side, but both should call |
| * {@link #doReadIdentification(Buffer, boolean)} and store the result in the needed property. |
| * |
| * @param buffer The {@link Buffer} containing the remote identification |
| * @return <code>true</code> if the identification has been fully read or <code>false</code> if more data |
| * is needed |
| * @throws Exception if an error occurs such as a bad protocol version or unsuccessful KEX was involved |
| */ |
| protected abstract boolean readIdentification(Buffer buffer) throws Exception; |
| |
| /** |
| * Send the key exchange initialization packet. This packet contains random data along with our proposal. |
| * |
| * @param proposal our proposal for key exchange negotiation |
| * @return the sent packet data which must be kept for later use when deriving the session keys |
| * @throws Exception if an error occurred sending the packet |
| */ |
| protected byte[] sendKexInit(Map<KexProposalOption, String> proposal) throws Exception { |
| boolean debugEnabled = log.isDebugEnabled(); |
| if (debugEnabled) { |
| log.debug("sendKexInit({}) Send SSH_MSG_KEXINIT", this); |
| } |
| |
| Buffer buffer = createBuffer(SshConstants.SSH_MSG_KEXINIT); |
| int p = buffer.wpos(); |
| buffer.wpos(p + SshConstants.MSG_KEX_COOKIE_SIZE); |
| synchronized (random) { |
| random.fill(buffer.array(), p, SshConstants.MSG_KEX_COOKIE_SIZE); |
| } |
| |
| boolean traceEnabled = log.isTraceEnabled(); |
| if (traceEnabled) { |
| log.trace("sendKexInit({}) cookie={}", |
| this, BufferUtils.toHex(buffer.array(), p, SshConstants.MSG_KEX_COOKIE_SIZE, ':')); |
| } |
| |
| for (KexProposalOption paramType : KexProposalOption.VALUES) { |
| String s = proposal.get(paramType); |
| if (traceEnabled) { |
| log.trace("sendKexInit({})[{}] {}", this, paramType.getDescription(), s); |
| } |
| buffer.putString(GenericUtils.trimToEmpty(s)); |
| } |
| |
| buffer.putBoolean(false); // first kex packet follows |
| buffer.putUInt(0L); // reserved (FFU) |
| |
| ReservedSessionMessagesHandler handler = getReservedSessionMessagesHandler(); |
| IoWriteFuture future = (handler == null) ? null : handler.sendKexInitRequest(this, proposal, buffer); |
| byte[] data = buffer.getCompactData(); |
| if (future == null) { |
| writePacket(buffer); |
| } else { |
| if (debugEnabled) { |
| log.debug("sendKexInit({}) KEX handled by reserved messages handler", this); |
| } |
| } |
| |
| return data; |
| } |
| |
| /** |
| * Receive the remote key exchange init message. The packet data is returned for later use. |
| * |
| * @param buffer the {@link Buffer} containing the key exchange init packet |
| * @param proposal the remote proposal to fill |
| * @return the packet data |
| * @throws Exception If failed to handle the message |
| */ |
| protected byte[] receiveKexInit(Buffer buffer, Map<KexProposalOption, String> proposal) throws Exception { |
| // Recreate the packet payload which will be needed at a later time |
| byte[] d = buffer.array(); |
| byte[] data = new byte[buffer.available() + 1 /* the opcode */]; |
| data[0] = SshConstants.SSH_MSG_KEXINIT; |
| |
| int size = 6; |
| int cookieStartPos = buffer.rpos(); |
| System.arraycopy(d, cookieStartPos, data, 1, data.length - 1); |
| // Skip random cookie data |
| buffer.rpos(cookieStartPos + SshConstants.MSG_KEX_COOKIE_SIZE); |
| size += SshConstants.MSG_KEX_COOKIE_SIZE; |
| |
| boolean traceEnabled = log.isTraceEnabled(); |
| if (traceEnabled) { |
| log.trace("receiveKexInit({}) cookie={}", |
| this, BufferUtils.toHex(d, cookieStartPos, SshConstants.MSG_KEX_COOKIE_SIZE, ':')); |
| } |
| |
| // Read proposal |
| for (KexProposalOption paramType : KexProposalOption.VALUES) { |
| int lastPos = buffer.rpos(); |
| String value = buffer.getString(); |
| if (traceEnabled) { |
| log.trace("receiveKexInit({})[{}] {}", this, paramType.getDescription(), value); |
| } |
| int curPos = buffer.rpos(); |
| int readLen = curPos - lastPos; |
| proposal.put(paramType, value); |
| size += readLen; |
| } |
| |
| KexExtensionHandler extHandler = getKexExtensionHandler(); |
| if (extHandler != null) { |
| if (traceEnabled) { |
| log.trace("receiveKexInit({}) options before handler: {}", this, proposal); |
| } |
| |
| extHandler.handleKexInitProposal(this, false, proposal); |
| |
| if (traceEnabled) { |
| log.trace("receiveKexInit({}) options after handler: {}", this, proposal); |
| } |
| } |
| |
| firstKexPacketFollows = buffer.getBoolean(); |
| if (traceEnabled) { |
| log.trace("receiveKexInit({}) first kex packet follows: {}", this, firstKexPacketFollows); |
| } |
| |
| long reserved = buffer.getUInt(); |
| if (reserved != 0L) { |
| if (traceEnabled) { |
| log.trace("receiveKexInit({}) non-zero reserved value: {}", this, reserved); |
| } |
| } |
| |
| // Return data |
| byte[] dataShrinked = new byte[size]; |
| System.arraycopy(data, 0, dataShrinked, 0, size); |
| return dataShrinked; |
| } |
| |
| /** |
| * Prepares the new ciphers, macs and compression algorithms according to the negotiated server and client proposals |
| * and stores them in {@link #inSettings} and {@link #outSettings}. The new settings do not take effect yet; use |
| * {@link #setInputEncoding()} or {@link #setOutputEncoding()} for that. |
| * |
| * @throws Exception if an error occurs |
| */ |
| @SuppressWarnings("checkstyle:VariableDeclarationUsageDistance") |
| protected void prepareNewKeys() throws Exception { |
| byte[] k = kex.getK(); |
| byte[] h = kex.getH(); |
| Digest hash = kex.getHash(); |
| |
| boolean debugEnabled = log.isDebugEnabled(); |
| if (sessionId == null) { |
| sessionId = h.clone(); |
| if (debugEnabled) { |
| log.debug("prepareNewKeys({}) session ID={}", this, BufferUtils.toHex(':', sessionId)); |
| } |
| } |
| |
| Buffer buffer = new ByteArrayBuffer(); |
| buffer.putMPInt(k); |
| buffer.putRawBytes(h); |
| buffer.putByte((byte) 0x41); |
| buffer.putRawBytes(sessionId); |
| |
| int pos = buffer.available(); |
| byte[] buf = buffer.array(); |
| hash.update(buf, 0, pos); |
| |
| byte[] iv_c2s = hash.digest(); |
| int j = pos - sessionId.length - 1; |
| |
| buf[j]++; |
| hash.update(buf, 0, pos); |
| byte[] iv_s2c = hash.digest(); |
| |
| buf[j]++; |
| hash.update(buf, 0, pos); |
| byte[] e_c2s = hash.digest(); |
| |
| buf[j]++; |
| hash.update(buf, 0, pos); |
| byte[] e_s2c = hash.digest(); |
| |
| buf[j]++; |
| hash.update(buf, 0, pos); |
| byte[] mac_c2s = hash.digest(); |
| |
| buf[j]++; |
| hash.update(buf, 0, pos); |
| byte[] mac_s2c = hash.digest(); |
| |
| boolean serverSession = isServerSession(); |
| String value = getNegotiatedKexParameter(KexProposalOption.S2CENC); |
| Cipher s2ccipher = ValidateUtils.checkNotNull( |
| NamedFactory.create(getCipherFactories(), value), "Unknown s2c cipher: %s", value); |
| e_s2c = resizeKey(e_s2c, s2ccipher.getKdfSize(), hash, k, h); |
| |
| Mac s2cmac; |
| if (s2ccipher.getAuthenticationTagSize() == 0) { |
| value = getNegotiatedKexParameter(KexProposalOption.S2CMAC); |
| s2cmac = NamedFactory.create(getMacFactories(), value); |
| if (s2cmac == null) { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_MAC_ERROR, "Unknown s2c MAC: " + value); |
| } |
| mac_s2c = resizeKey(mac_s2c, s2cmac.getBlockSize(), hash, k, h); |
| s2cmac.init(mac_s2c); |
| } else { |
| s2cmac = null; |
| } |
| |
| value = getNegotiatedKexParameter(KexProposalOption.S2CCOMP); |
| Compression s2ccomp = NamedFactory.create(getCompressionFactories(), value); |
| if (s2ccomp == null) { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_COMPRESSION_ERROR, "Unknown s2c compression: " + value); |
| } |
| |
| value = getNegotiatedKexParameter(KexProposalOption.C2SENC); |
| Cipher c2scipher = ValidateUtils.checkNotNull( |
| NamedFactory.create(getCipherFactories(), value), "Unknown c2s cipher: %s", value); |
| e_c2s = resizeKey(e_c2s, c2scipher.getKdfSize(), hash, k, h); |
| |
| Mac c2smac; |
| if (c2scipher.getAuthenticationTagSize() == 0) { |
| value = getNegotiatedKexParameter(KexProposalOption.C2SMAC); |
| c2smac = NamedFactory.create(getMacFactories(), value); |
| if (c2smac == null) { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_MAC_ERROR, "Unknown c2s MAC: " + value); |
| } |
| mac_c2s = resizeKey(mac_c2s, c2smac.getBlockSize(), hash, k, h); |
| c2smac.init(mac_c2s); |
| } else { |
| c2smac = null; |
| } |
| |
| value = getNegotiatedKexParameter(KexProposalOption.C2SCOMP); |
| Compression c2scomp = NamedFactory.create(getCompressionFactories(), value); |
| if (c2scomp == null) { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_COMPRESSION_ERROR, "Unknown c2s compression: " + value); |
| } |
| |
| if (serverSession) { |
| outSettings = new MessageCodingSettings(s2ccipher, s2cmac, s2ccomp, Cipher.Mode.Encrypt, e_s2c, iv_s2c); |
| inSettings = new MessageCodingSettings(c2scipher, c2smac, c2scomp, Cipher.Mode.Decrypt, e_c2s, iv_c2s); |
| } else { |
| outSettings = new MessageCodingSettings(c2scipher, c2smac, c2scomp, Cipher.Mode.Encrypt, e_c2s, iv_c2s); |
| inSettings = new MessageCodingSettings(s2ccipher, s2cmac, s2ccomp, Cipher.Mode.Decrypt, e_s2c, iv_s2c); |
| } |
| } |
| |
| /** |
| * Installs the current prepared {@link #outSettings} so that they are effective and will be applied to any future |
| * outgoing packet. Clears {@link #outSettings}. |
| * |
| * @throws Exception on errors |
| */ |
| protected void setOutputEncoding() throws Exception { |
| outCipher = outSettings.getCipher(seqo); |
| outMac = outSettings.getMac(); |
| outCompression = outSettings.getCompression(); |
| outSettings = null; |
| outCipherSize = outCipher.getCipherBlockSize(); |
| outMacSize = outMac != null ? outMac.getBlockSize() : 0; |
| // TODO add support for configurable compression level |
| outCompression.init(Compression.Type.Deflater, -1); |
| |
| maxRekeyBlocks.set(determineRekeyBlockLimit(inCipherSize, outCipherSize)); |
| |
| outBytesCount.set(0L); |
| outPacketsCount.set(0L); |
| outBlocksCount.set(0L); |
| |
| lastKeyTimeValue.set(Instant.now()); |
| firstKexPacketFollows = null; |
| |
| if (log.isDebugEnabled()) { |
| log.debug("setOutputEncoding({}): cipher {}; mac {}; compression {}; blocks limit {}", this, outCipher, outMac, |
| outCompression, maxRekeyBlocks); |
| } |
| } |
| |
| /** |
| * Installs the current prepared {@link #inSettings} so that they are effective and will be applied to any future |
| * incoming packet. Clears {@link #inSettings}. |
| * |
| * @throws Exception on errors |
| */ |
| protected void setInputEncoding() throws Exception { |
| inCipher = inSettings.getCipher(seqi); |
| inMac = inSettings.getMac(); |
| inCompression = inSettings.getCompression(); |
| inSettings = null; |
| inCipherSize = inCipher.getCipherBlockSize(); |
| inMacSize = inMac != null ? inMac.getBlockSize() : 0; |
| inMacResult = new byte[inMacSize]; |
| // TODO add support for configurable compression level |
| inCompression.init(Compression.Type.Inflater, -1); |
| |
| maxRekeyBlocks.set(determineRekeyBlockLimit(inCipherSize, outCipherSize)); |
| |
| inBytesCount.set(0L); |
| inPacketsCount.set(0L); |
| inBlocksCount.set(0L); |
| |
| lastKeyTimeValue.set(Instant.now()); |
| firstKexPacketFollows = null; |
| |
| if (log.isDebugEnabled()) { |
| log.debug("setInputEncoding({}): cipher {}; mac {}; compression {}; blocks limit {}", this, inCipher, inMac, |
| inCompression, maxRekeyBlocks); |
| } |
| } |
| |
| /** |
| * Compute the number of blocks after which we should re-key again. See RFC 4344. |
| * |
| * @param inCipherBlockSize block size of the input cipher |
| * @param outCipherBlockSize block size of the output cipher |
| * @return the number of block after which re-keying occur at the latest |
| * @see <a href= "https://tools.ietf.org/html/rfc4344#section-3.2">RFC 4344, section 3.2</a> |
| */ |
| protected long determineRekeyBlockLimit(int inCipherBlockSize, int outCipherBlockSize) { |
| // see https://tools.ietf.org/html/rfc4344#section-3.2 |
| // select the lowest cipher size |
| long rekeyBlocksLimit = CoreModuleProperties.REKEY_BLOCKS_LIMIT.getRequired(this); |
| if (rekeyBlocksLimit <= 0) { |
| // Default per RFC 4344 |
| int minCipherBlockBytes = Math.min(inCipherSize, outCipherSize); |
| if (minCipherBlockBytes >= 16) { |
| rekeyBlocksLimit = 1L << Math.min(minCipherBlockBytes * 2, 63); |
| } else { |
| // With a block size of 8 we'd end up with 2^16. That would re-key very often. |
| // RFC 4344: "If L is less than 128 [...], then, although it may be too |
| // expensive to rekey every 2**(L/4) blocks, it is still advisable for SSH |
| // implementations to follow the original recommendation in [RFC4253]: rekey at |
| // least once for every gigabyte of transmitted data." |
| // |
| // Note that chacha20-poly1305 has a block size of 8. The OpenSSH recommendation |
| // is: "ChaCha20 must never reuse a {key, nonce} for encryption nor may it be |
| // used to encrypt more than 2^70 bytes under the same {key, nonce}. The |
| // SSH Transport protocol (RFC4253) recommends a far more conservative |
| // rekeying every 1GB of data sent or received. If this recommendation |
| // is followed, then chacha20-poly1305@openssh.com requires no special |
| // handling in this area." |
| rekeyBlocksLimit = (1L << 30) / minCipherBlockBytes; // 1GB |
| } |
| } |
| return rekeyBlocksLimit; |
| } |
| |
| /** |
| * Send a {@code SSH_MSG_UNIMPLEMENTED} packet. This packet should contain the sequence id of the unsupported |
| * packet: this number is assumed to be the last packet received. |
| * |
| * @param cmd The un-implemented command value |
| * @param buffer The {@link Buffer} that contains the command. <b>Note:</b> the buffer's read position is just |
| * beyond the command. |
| * @return An {@link IoWriteFuture} that can be used to wait for packet write completion - {@code null} if |
| * the registered {@link ReservedSessionMessagesHandler} decided to handle the command internally |
| * @throws Exception if an error occurred while handling the packet. |
| * @see #sendNotImplemented(long) |
| */ |
| protected IoWriteFuture notImplemented(int cmd, Buffer buffer) throws Exception { |
| if (doInvokeUnimplementedMessageHandler(cmd, buffer)) { |
| return null; |
| } |
| |
| return sendNotImplemented(seqi - 1L); |
| } |
| |
| /** |
| * Compute the negotiated proposals by merging the client and server proposal. The negotiated proposal will also be |
| * stored in the {@link #negotiationResult} property. |
| * |
| * @return The negotiated options {@link Map} |
| * @throws Exception If negotiation failed |
| */ |
| protected Map<KexProposalOption, String> negotiate() throws Exception { |
| Map<KexProposalOption, String> c2sOptions = getClientKexProposals(); |
| Map<KexProposalOption, String> s2cOptions = getServerKexProposals(); |
| signalNegotiationStart(c2sOptions, s2cOptions); |
| |
| Map<KexProposalOption, String> guess = new EnumMap<>(KexProposalOption.class); |
| Map<KexProposalOption, String> negotiatedGuess = Collections.unmodifiableMap(guess); |
| try { |
| boolean debugEnabled = log.isDebugEnabled(); |
| boolean traceEnabled = log.isTraceEnabled(); |
| SessionDisconnectHandler discHandler = getSessionDisconnectHandler(); |
| KexExtensionHandler extHandler = getKexExtensionHandler(); |
| for (KexProposalOption paramType : KexProposalOption.VALUES) { |
| String clientParamValue = c2sOptions.get(paramType); |
| String serverParamValue = s2cOptions.get(paramType); |
| String[] c = GenericUtils.split(clientParamValue, ','); |
| String[] s = GenericUtils.split(serverParamValue, ','); |
| /* |
| * According to https://tools.ietf.org/html/rfc8308#section-2.2: |
| * |
| * Implementations MAY disconnect if the counterpart sends an incorrect (KEX extension) indicator |
| * |
| * TODO - for now we do not enforce this |
| */ |
| for (String ci : c) { |
| for (String si : s) { |
| if (ci.equals(si)) { |
| guess.put(paramType, ci); |
| break; |
| } |
| } |
| |
| String value = guess.get(paramType); |
| if (value != null) { |
| break; |
| } |
| } |
| |
| // check if reached an agreement |
| String value = guess.get(paramType); |
| if (extHandler != null) { |
| extHandler.handleKexExtensionNegotiation( |
| this, paramType, value, c2sOptions, clientParamValue, s2cOptions, serverParamValue); |
| } |
| |
| if (value != null) { |
| if (traceEnabled) { |
| log.trace("negotiate({})[{}] guess={} (client={} / server={})", |
| this, paramType.getDescription(), value, clientParamValue, serverParamValue); |
| } |
| continue; |
| } |
| |
| try { |
| if ((discHandler != null) |
| && discHandler.handleKexDisconnectReason( |
| this, c2sOptions, s2cOptions, negotiatedGuess, paramType)) { |
| if (debugEnabled) { |
| log.debug("negotiate({}) ignore missing value for KEX option={}", this, paramType); |
| } |
| continue; |
| } |
| } catch (IOException | RuntimeException e) { |
| // If disconnect handler throws an exception continue with the disconnect |
| debug("negotiate({}) failed ({}) to invoke disconnect handler due to mismatched KEX option={}: {}", |
| this, e.getClass().getSimpleName(), paramType, e.getMessage(), e); |
| } |
| |
| String message = "Unable to negotiate key exchange for " + paramType.getDescription() |
| + " (client: " + clientParamValue + " / server: " + serverParamValue + ")"; |
| // OK if could not negotiate languages |
| if (KexProposalOption.S2CLANG.equals(paramType) || KexProposalOption.C2SLANG.equals(paramType)) { |
| if (traceEnabled) { |
| log.trace("negotiate({}) {}", this, message); |
| } |
| } else { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED, message); |
| } |
| } |
| |
| /* |
| * According to https://tools.ietf.org/html/rfc8308#section-2.2: |
| * |
| * If "ext-info-c" or "ext-info-s" ends up being negotiated as a key exchange method, the parties MUST |
| * disconnect. |
| */ |
| String kexOption = guess.get(KexProposalOption.ALGORITHMS); |
| if (KexExtensions.IS_KEX_EXTENSION_SIGNAL.test(kexOption)) { |
| if ((discHandler != null) |
| && discHandler.handleKexDisconnectReason( |
| this, c2sOptions, s2cOptions, negotiatedGuess, KexProposalOption.ALGORITHMS)) { |
| if (debugEnabled) { |
| log.debug("negotiate({}) ignore violating {} KEX option={}", this, KexProposalOption.ALGORITHMS, |
| kexOption); |
| } |
| } else { |
| throw new SshException(SshConstants.SSH2_DISCONNECT_KEY_EXCHANGE_FAILED, |
| "Illegal KEX option negotiated: " + kexOption); |
| } |
| } |
| } catch (IOException | RuntimeException | Error e) { |
| signalNegotiationEnd(c2sOptions, s2cOptions, negotiatedGuess, e); |
| throw e; |
| } |
| |
| signalNegotiationEnd(c2sOptions, s2cOptions, negotiatedGuess, null); |
| return setNegotiationResult(guess); |
| } |
| |
| protected Map<KexProposalOption, String> setNegotiationResult(Map<KexProposalOption, String> guess) { |
| synchronized (negotiationResult) { |
| if (!negotiationResult.isEmpty()) { |
| negotiationResult.clear(); // debug breakpoint |
| } |
| negotiationResult.putAll(guess); |
| } |
| |
| if (log.isDebugEnabled()) { |
| guess.forEach((option, value) -> log.debug("setNegotiationResult({}) Kex: {} = {}", this, |
| option.getDescription(), value)); |
| } |
| |
| return guess; |
| } |
| |
| /** |
| * Indicates the reception of a {@code SSH_MSG_REQUEST_SUCCESS} message |
| * |
| * @param buffer The {@link Buffer} containing the message data |
| * @throws Exception If failed to handle the message |
| */ |
| protected void requestSuccess(Buffer buffer) throws Exception { |
| resetIdleTimeout(); |
| // Remove at end |
| GlobalRequestFuture request = pendingGlobalRequests.pollLast(); |
| if (request != null) { |
| // use a copy of the original data in case it is re-used on return |
| Buffer resultBuf = ByteArrayBuffer.getCompactClone(buffer.array(), buffer.rpos(), buffer.available()); |
| GlobalRequestFuture.ReplyHandler handler = request.getHandler(); |
| if (handler != null) { |
| handler.accept(SshConstants.SSH_MSG_REQUEST_SUCCESS, resultBuf); |
| } else { |
| request.setValue(resultBuf); |
| } |
| } |
| } |
| |
| /** |
| * Indicates the reception of a {@code SSH_MSG_REQUEST_FAILURE} message |
| * |
| * @param buffer The {@link Buffer} containing the message data |
| * @throws Exception If failed to handle the message |
| */ |
| protected void requestFailure(Buffer buffer) throws Exception { |
| resetIdleTimeout(); |
| // Remove at end |
| GlobalRequestFuture request = pendingGlobalRequests.pollLast(); |
| if (request != null) { |
| GlobalRequestFuture.ReplyHandler handler = request.getHandler(); |
| if (handler != null) { |
| Buffer resultBuf = ByteArrayBuffer.getCompactClone(buffer.array(), buffer.rpos(), buffer.available()); |
| handler.accept(SshConstants.SSH_MSG_REQUEST_FAILURE, resultBuf); |
| } else { |
| request.setValue(new GlobalRequestException(SshConstants.SSH_MSG_REQUEST_FAILURE)); |
| } |
| } |
| } |
| |
| @Override |
| public void addSessionListener(SessionListener listener) { |
| SessionListener.validateListener(listener); |
| // avoid race conditions on notifications while session is being closed |
| if (!isOpen()) { |
| log.warn("addSessionListener({})[{}] ignore registration while session is closing", this, listener); |
| return; |
| } |
| |
| if (this.sessionListeners.add(listener)) { |
| if (log.isTraceEnabled()) { |
| log.trace("addSessionListener({})[{}] registered", this, listener); |
| } |
| } else { |
| if (log.isTraceEnabled()) { |
| log.trace("addSessionListener({})[{}] ignored duplicate", this, listener); |
| } |
| } |
| } |
| |
| @Override |
| public void removeSessionListener(SessionListener listener) { |
| if (listener == null) { |
| return; |
| } |
| |
| SessionListener.validateListener(listener); |
| if (this.sessionListeners.remove(listener)) { |
| if (log.isTraceEnabled()) { |
| log.trace("removeSessionListener({})[{}] removed", this, listener); |
| } |
| } else { |
| if (log.isTraceEnabled()) { |
| log.trace("removeSessionListener({})[{}] not registered", this, listener); |
| } |
| } |
| } |
| |
| @Override |
| public SessionListener getSessionListenerProxy() { |
| return sessionListenerProxy; |
| } |
| |
| @Override |
| public void addChannelListener(ChannelListener listener) { |
| ChannelListener.validateListener(listener); |
| // avoid race conditions on notifications while session is being closed |
| if (!isOpen()) { |
| log.warn("addChannelListener({})[{}] ignore registration while session is closing", this, listener); |
| return; |
| } |
| |
| if (this.channelListeners.add(listener)) { |
| if (log.isTraceEnabled()) { |
| log.trace("addChannelListener({})[{}] registered", this, listener); |
| } |
| } else { |
| if (log.isTraceEnabled()) { |
| log.trace("addChannelListener({})[{}] ignored duplicate", this, listener); |
| } |
| } |
| } |
| |
| @Override |
| public void removeChannelListener(ChannelListener listener) { |
| if (listener == null) { |
| return; |
| } |
| |
| ChannelListener.validateListener(listener); |
| if (this.channelListeners.remove(listener)) { |
| if (log.isTraceEnabled()) { |
| log.trace("removeChannelListener({})[{}] removed", this, listener); |
| } |
| } else { |
| if (log.isTraceEnabled()) { |
| log.trace("removeChannelListener({})[{}] not registered", this, listener); |
| } |
| } |
| } |
| |
| @Override |
| public ChannelListener getChannelListenerProxy() { |
| return channelListenerProxy; |
| } |
| |
| @Override |
| public PortForwardingEventListener getPortForwardingEventListenerProxy() { |
| return tunnelListenerProxy; |
| } |
| |
| @Override |
| public void addPortForwardingEventListener(PortForwardingEventListener listener) { |
| PortForwardingEventListener.validateListener(listener); |
| // avoid race conditions on notifications while session is being closed |
| if (!isOpen()) { |
| log.warn("addPortForwardingEventListener({})[{}] ignore registration while session is closing", this, listener); |
| return; |
| } |
| |
| if (this.tunnelListeners.add(listener)) { |
| if (log.isTraceEnabled()) { |
| log.trace("addPortForwardingEventListener({})[{}] registered", this, listener); |
| } |
| } else { |
| if (log.isTraceEnabled()) { |
| log.trace("addPortForwardingEventListener({})[{}] ignored duplicate", this, listener); |
| } |
| } |
| } |
| |
| @Override |
| public void removePortForwardingEventListener(PortForwardingEventListener listener) { |
| if (listener == null) { |
| return; |
| } |
| |
| PortForwardingEventListener.validateListener(listener); |
| if (this.tunnelListeners.remove(listener)) { |
| if (log.isTraceEnabled()) { |
| log.trace("removePortForwardingEventListener({})[{}] removed", this, listener); |
| } |
| } else { |
| if (log.isTraceEnabled()) { |
| log.trace("removePortForwardingEventListener({})[{}] not registered", this, listener); |
| } |
| } |
| } |
| |
| @Override |
| public KeyExchangeFuture reExchangeKeys() throws IOException { |
| try { |
| requestNewKeysExchange(); |
| } catch (GeneralSecurityException e) { |
| debug("reExchangeKeys({}) failed ({}) to request new keys: {}", |
| this, e.getClass().getSimpleName(), e.getMessage(), e); |
| throw ValidateUtils.initializeExceptionCause( |
| new ProtocolException("Failed (" + e.getClass().getSimpleName() + ")" |
| + " to generate keys for exchange: " + e.getMessage()), |
| e); |
| } catch (Exception e) { |
| ExceptionUtils.rethrowAsIoException(e); |
| return null; // actually dead code |
| } |
| |
| return ValidateUtils.checkNotNull( |
| kexFutureHolder.get(), "No current KEX future on state=%s", kexState); |
| } |
| |
| /** |
| * Checks if a re-keying is required and if so initiates it |
| * |
| * @return A {@link KeyExchangeFuture} to wait for the initiated exchange or {@code null} if no need to |
| * re-key or an exchange is already in progress |
| * @throws Exception If failed load/generate the keys or send the request |
| * @see #isRekeyRequired() |
| * @see #requestNewKeysExchange() |
| */ |
| protected KeyExchangeFuture checkRekey() throws Exception { |
| return isRekeyRequired() ? requestNewKeysExchange() : null; |
| } |
| |
| /** |
| * Initiates a new keys exchange if one not already in progress |
| * |
| * @return A {@link KeyExchangeFuture} to wait for the initiated exchange or {@code null} if an exchange |
| * is already in progress |
| * @throws Exception If failed to load/generate the keys or send the request |
| */ |
| protected KeyExchangeFuture requestNewKeysExchange() throws Exception { |
| boolean kexRunning = kexHandler.updateState(() -> { |
| boolean isRunning = !kexState.compareAndSet(KexState.DONE, KexState.INIT); |
| if (!isRunning) { |
| kexHandler.initNewKeyExchange(); |
| } |
| return Boolean.valueOf(isRunning); |
| }).booleanValue(); |
| |
| if (kexRunning) { |
| if (log.isDebugEnabled()) { |
| log.debug("requestNewKeysExchange({}) KEX state not DONE: {}", this, kexState); |
| } |
| |
| return null; |
| } |
| |
| log.info("requestNewKeysExchange({}) Initiating key re-exchange", this); |
| |
| DefaultKeyExchangeFuture newFuture = new DefaultKeyExchangeFuture(toString(), null); |
| DefaultKeyExchangeFuture kexFuture = kexFutureHolder.getAndSet(newFuture); |
| if (kexFuture != null) { |
| // Should actually never do anything. We don't reset the kexFuture at the end of KEX, and we do check for a |
| // running KEX above. The old future should in all cases be fulfilled already. |
| kexFuture.setValue(new SshException("New KEX started while previous one still ongoing")); |
| } |
| |
| sendKexInit(); |
| return newFuture; |
| } |
| |
| protected boolean isRekeyRequired() { |
| if ((!isOpen()) || isClosing() || isClosed()) { |
| return false; |
| } |
| |
| KexState curState = kexState.get(); |
| if (!KexState.DONE.equals(curState)) { |
| return false; |
| } |
| |
| return isRekeyTimeIntervalExceeded() |
| || isRekeyPacketCountsExceeded() |
| || isRekeyBlocksCountExceeded() |
| || isRekeyDataSizeExceeded(); |
| } |
| |
| protected boolean isRekeyTimeIntervalExceeded() { |
| if (GenericUtils.isNegativeOrNull(maxRekeyInterval)) { |
| return false; // disabled |
| } |
| |
| Instant now = Instant.now(); |
| Duration rekeyDiff = Duration.between(lastKeyTimeValue.get(), now); |
| boolean rekey = rekeyDiff.compareTo(maxRekeyInterval) > 0; |
| if (rekey) { |
| if (log.isDebugEnabled()) { |
| log.debug("isRekeyTimeIntervalExceeded({}) re-keying: last={}, now={}, diff={}, max={}", |
| this, lastKeyTimeValue.get(), now, rekeyDiff, maxRekeyInterval); |
| } |
| } |
| |
| return rekey; |
| } |
| |
| protected boolean isRekeyPacketCountsExceeded() { |
| if (maxRekyPackets <= 0L) { |
| return false; // disabled |
| } |
| |
| boolean rekey = (inPacketsCount.get() > maxRekyPackets) |
| || (outPacketsCount.get() > maxRekyPackets); |
| if (rekey) { |
| if (log.isDebugEnabled()) { |
| log.debug("isRekeyPacketCountsExceeded({}) re-keying: in={}, out={}, max={}", |
| this, inPacketsCount, outPacketsCount, maxRekyPackets); |
| } |
| } |
| |
| return rekey; |
| } |
| |
| protected boolean isRekeyDataSizeExceeded() { |
| if (maxRekeyBytes <= 0L) { |
| return false; |
| } |
| |
| boolean rekey = (inBytesCount.get() > maxRekeyBytes) || (outBytesCount.get() > maxRekeyBytes); |
| if (rekey) { |
| if (log.isDebugEnabled()) { |
| log.debug("isRekeyDataSizeExceeded({}) re-keying: in={}, out={}, max={}", |
| this, inBytesCount, outBytesCount, maxRekeyBytes); |
| } |
| } |
| |
| return rekey; |
| } |
| |
| protected boolean isRekeyBlocksCountExceeded() { |
| long maxBlocks = maxRekeyBlocks.get(); |
| if (maxBlocks <= 0L) { |
| return false; |
| } |
| |
| boolean rekey = (inBlocksCount.get() > maxBlocks) || (outBlocksCount.get() > maxBlocks); |
| if (rekey) { |
| if (log.isDebugEnabled()) { |
| log.debug("isRekeyBlocksCountExceeded({}) re-keying: in={}, out={}, max={}", |
| this, inBlocksCount, outBlocksCount, maxBlocks); |
| } |
| } |
| |
| return rekey; |
| } |
| |
| @Override |
| protected String resolveSessionKexProposal(String hostKeyTypes) throws IOException { |
| String proposal = super.resolveSessionKexProposal(hostKeyTypes); |
| // see https://tools.ietf.org/html/rfc8308 |
| KexExtensionHandler extHandler = getKexExtensionHandler(); |
| if ((extHandler == null) || (!extHandler.isKexExtensionsAvailable(this, AvailabilityPhase.PROPOSAL))) { |
| return proposal; |
| } |
| |
| String extType = isServerSession() ? KexExtensions.SERVER_KEX_EXTENSION : KexExtensions.CLIENT_KEX_EXTENSION; |
| if (GenericUtils.isEmpty(proposal)) { |
| return extType; |
| } else { |
| return proposal + "," + extType; |
| } |
| } |
| |
| protected byte[] sendKexInit() throws Exception { |
| Map<KexProposalOption, String> proposal = getKexProposal(); |
| |
| byte[] seed; |
| synchronized (kexState) { |
| DefaultKeyExchangeFuture initFuture = kexInitializedFuture; |
| if (initFuture == null) { |
| initFuture = new DefaultKeyExchangeFuture(toString(), null); |
| kexInitializedFuture = initFuture; |
| } |
| try { |
| seed = sendKexInit(proposal); |
| setKexSeed(seed); |
| initFuture.setValue(Boolean.TRUE); |
| } catch (Exception e) { |
| initFuture.setValue(e); |
| throw e; |
| } |
| } |
| |
| if (log.isTraceEnabled()) { |
| log.trace("sendKexInit({}) proposal={} seed: {}", this, proposal, BufferUtils.toHex(':', seed)); |
| } |
| return seed; |
| } |
| |
| protected byte[] getClientKexData() { |
| synchronized (kexState) { |
| return (clientKexData == null) ? null : clientKexData.clone(); |
| } |
| } |
| |
| protected void setClientKexData(byte[] data) { |
| ValidateUtils.checkNotNullAndNotEmpty(data, "No client KEX seed"); |
| synchronized (kexState) { |
| clientKexData = data.clone(); |
| } |
| } |
| |
| protected byte[] getServerKexData() { |
| synchronized (kexState) { |
| return (serverKexData == null) ? null : serverKexData.clone(); |
| } |
| } |
| |
| protected void setServerKexData(byte[] data) { |
| ValidateUtils.checkNotNullAndNotEmpty(data, "No server KEX seed"); |
| synchronized (kexState) { |
| serverKexData = data.clone(); |
| } |
| } |
| |
| /** |
| * @param seed The result of the KEXINIT handshake - required for correct session key establishment |
| */ |
| protected abstract void setKexSeed(byte... seed); |
| |
| /** |
| * Indicates the the key exchange is completed and the exchanged keys can now be verified - e.g., client can verify |
| * the server's key |
| * |
| * @throws IOException If validation failed |
| */ |
| protected abstract void checkKeys() throws IOException; |
| |
| protected byte[] receiveKexInit(Buffer buffer) throws Exception { |
| Map<KexProposalOption, String> proposal = new EnumMap<>(KexProposalOption.class); |
| |
| byte[] seed; |
| synchronized (kexState) { |
| seed = receiveKexInit(buffer, proposal); |
| receiveKexInit(proposal, seed); |
| } |
| |
| if (log.isTraceEnabled()) { |
| log.trace("receiveKexInit({}) proposal={} seed: {}", |
| this, proposal, BufferUtils.toHex(':', seed)); |
| } |
| |
| return seed; |
| } |
| |
| protected abstract void receiveKexInit( |
| Map<KexProposalOption, String> proposal, byte[] seed) |
| throws IOException; |
| |
| /** |
| * Retrieve the SSH session from the I/O session. If the session has not been attached, an exception will be thrown |
| * |
| * @param ioSession The {@link IoSession} |
| * @return The SSH session attached to the I/O session |
| * @see #getSession(IoSession, boolean) |
| * @throws MissingAttachedSessionException if no attached SSH session |
| */ |
| public static AbstractSession getSession(IoSession ioSession) |
| throws MissingAttachedSessionException { |
| return getSession(ioSession, false); |
| } |
| |
| /** |
| * Attach an SSH {@link AbstractSession} to the I/O session |
| * |
| * @param ioSession The {@link IoSession} |
| * @param session The SSH session to attach |
| * @throws MultipleAttachedSessionException If a previous session already attached |
| */ |
| public static void attachSession(IoSession ioSession, AbstractSession session) |
| throws MultipleAttachedSessionException { |
| Objects.requireNonNull(ioSession, "No I/O session"); |
| Objects.requireNonNull(session, "No SSH session"); |
| Object prev = ioSession.setAttributeIfAbsent(SESSION, session); |
| if (prev != null) { |
| throw new MultipleAttachedSessionException( |
| "Multiple attached session to " + ioSession + ": " + prev + " and " + session); |
| } |
| } |
| |
| /** |
| * Retrieve the session SSH from the I/O session. If the session has not been attached and <tt>allowNull</tt> is |
| * <code>false</code>, an exception will be thrown, otherwise a {@code null} will be returned. |
| * |
| * @param ioSession The {@link IoSession} |
| * @param allowNull If <code>true</code>, a {@code null} value may be returned if no session |
| * is attached |
| * @return the session attached to the I/O session or {@code null} |
| * @throws MissingAttachedSessionException if no attached session and <tt>allowNull=false</tt> |
| */ |
| public static AbstractSession getSession(IoSession ioSession, boolean allowNull) |
| throws MissingAttachedSessionException { |
| AbstractSession session = (AbstractSession) ioSession.getAttribute(SESSION); |
| if ((session == null) && (!allowNull)) { |
| throw new MissingAttachedSessionException("No session attached to " + ioSession); |
| } |
| |
| return session; |
| } |
| |
| /** |
| * Message encoding or decoding settings as determined at the end of a key exchange. |
| */ |
| protected static class MessageCodingSettings { |
| |
| private final Cipher cipher; |
| |
| private final Mac mac; |
| |
| private final Compression compression; |
| |
| private final Cipher.Mode mode; |
| |
| private byte[] key; |
| |
| private byte[] iv; |
| |
| public MessageCodingSettings(Cipher cipher, Mac mac, Compression compression, Cipher.Mode mode, byte[] key, byte[] iv) { |
| this.cipher = cipher; |
| this.mac = mac; |
| this.compression = compression; |
| this.mode = mode; |
| this.key = key.clone(); |
| this.iv = iv.clone(); |
| } |
| |
| private void initCipher(long packetSequenceNumber) throws Exception { |
| if (key != null) { |
| if (cipher.getAlgorithm().startsWith("ChaCha")) { |
| BufferUtils.putLong(packetSequenceNumber, iv, 0, iv.length); |
| } |
| cipher.init(mode, key, iv); |
| key = null; |
| } |
| } |
| |
| /** |
| * Get the {@link Cipher}. |
| * |
| * @param packetSequenceNumber SSH packet sequence number for initializing the cipher. Pass {@link #seqo} if |
| * the cipher is to be used for output, {@link #seqi} otherwise. |
| * @return the fully initialized cipher |
| * @throws Exception if the cipher cannot be initialized |
| */ |
| public Cipher getCipher(long packetSequenceNumber) throws Exception { |
| initCipher(packetSequenceNumber); |
| return cipher; |
| } |
| |
| public Mac getMac() { |
| return mac; |
| } |
| |
| public Compression getCompression() { |
| return compression; |
| } |
| } |
| } |