SshAgentClient.java

/*
 * Copyright (C) 2021, Thomas Wolf <thomas.wolf@paranor.ch> and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */
package org.eclipse.jgit.internal.transport.sshd.agent;

import java.io.IOException;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.text.MessageFormat;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.sshd.agent.SshAgent;
import org.apache.sshd.agent.SshAgentConstants;
import org.apache.sshd.agent.SshAgentKeyConstraint;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.config.keys.KeyUtils;
import org.apache.sshd.common.keyprovider.KeyPairProvider;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.buffer.BufferException;
import org.apache.sshd.common.util.buffer.BufferUtils;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
import org.apache.sshd.common.util.buffer.keys.BufferPublicKeyParser;
import org.apache.sshd.common.util.io.der.DERParser;
import org.eclipse.jgit.internal.transport.sshd.SshdText;
import org.eclipse.jgit.transport.sshd.agent.Connector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A client for an SSH2 agent. This client supports querying identities,
 * signature requests, and adding keys to an agent (with or without
 * constraints). Removing keys is not supported, and the older SSH1 protocol is
 * not supported.
 *
 * @see <a href="https://tools.ietf.org/html/draft-miller-ssh-agent-04">SSH
 *      Agent Protocol, RFC draft</a>
 */
public class SshAgentClient implements SshAgent {

	private static final Logger LOG = LoggerFactory
			.getLogger(SshAgentClient.class);

	// OpenSSH limit
	private static final int MAX_NUMBER_OF_KEYS = 2048;

	private final AtomicBoolean closed = new AtomicBoolean();

	private final Connector connector;

	/**
	 * Creates a new {@link SshAgentClient} implementing the SSH2 ssh agent
	 * protocol, using the given {@link Connector} to connect to the SSH agent
	 * and to exchange messages.
	 *
	 * @param connector
	 *            {@link Connector} to use
	 */
	public SshAgentClient(Connector connector) {
		this.connector = connector;
	}

	private boolean open(boolean debugging) throws IOException {
		if (closed.get()) {
			if (debugging) {
				LOG.debug("SSH agent connection already closed"); //$NON-NLS-1$
			}
			return false;
		}
		boolean connected;
		try {
			connected = connector != null && connector.connect();
			if (!connected && debugging) {
				LOG.debug("No SSH agent"); //$NON-NLS-1$
			}
		} catch (IOException e) {
			// Agent not running?
			if (debugging) {
				LOG.debug("No SSH agent", e); //$NON-NLS-1$
			}
			throw e;
		}
		return connected;
	}

	@Override
	public void close() throws IOException {
		if (!closed.getAndSet(true) && connector != null) {
			connector.close();
		}
	}

	@Override
	public Iterable<? extends Map.Entry<PublicKey, String>> getIdentities()
			throws IOException {
		boolean debugging = LOG.isDebugEnabled();
		if (!open(debugging)) {
			return Collections.emptyList();
		}
		if (debugging) {
			LOG.debug("Requesting identities from SSH agent"); //$NON-NLS-1$
		}
		try {
			Buffer reply = rpc(
					SshAgentConstants.SSH2_AGENTC_REQUEST_IDENTITIES);
			byte cmd = reply.getByte();
			if (cmd != SshAgentConstants.SSH2_AGENT_IDENTITIES_ANSWER) {
				throw new SshException(MessageFormat.format(
						SshdText.get().sshAgentReplyUnexpected,
						SshAgentConstants.getCommandMessageName(cmd)));
			}
			int numberOfKeys = reply.getInt();
			if (numberOfKeys < 0 || numberOfKeys > MAX_NUMBER_OF_KEYS) {
				throw new SshException(MessageFormat.format(
						SshdText.get().sshAgentWrongNumberOfKeys,
						Integer.toString(numberOfKeys)));
			}
			if (numberOfKeys == 0) {
				if (debugging) {
					LOG.debug("SSH agent has no keys"); //$NON-NLS-1$
				}
				return Collections.emptyList();
			}
			if (debugging) {
				LOG.debug("Got {} key(s) from the SSH agent", //$NON-NLS-1$
						Integer.toString(numberOfKeys));
			}
			boolean tracing = LOG.isTraceEnabled();
			List<Map.Entry<PublicKey, String>> keys = new ArrayList<>(
					numberOfKeys);
			for (int i = 0; i < numberOfKeys; i++) {
				PublicKey key = readKey(reply);
				String comment = reply.getString();
				if (key != null) {
					if (tracing) {
						LOG.trace("Got SSH agent {} key: {} {}", //$NON-NLS-1$
								KeyUtils.getKeyType(key),
								KeyUtils.getFingerPrint(key), comment);
					}
					keys.add(new AbstractMap.SimpleImmutableEntry<>(key,
							comment));
				}
			}
			return keys;
		} catch (BufferException e) {
			throw new SshException(SshdText.get().sshAgentShortReadBuffer, e);
		}
	}

	@Override
	public Map.Entry<String, byte[]> sign(SessionContext session, PublicKey key,
			String algorithm, byte[] data) throws IOException {
		boolean debugging = LOG.isDebugEnabled();
		String keyType = KeyUtils.getKeyType(key);
		String signatureAlgorithm;
		if (algorithm != null) {
			if (!KeyUtils.getCanonicalKeyType(algorithm).equals(keyType)) {
				throw new IllegalArgumentException(MessageFormat.format(
						SshdText.get().invalidSignatureAlgorithm, algorithm,
						keyType));
			}
			signatureAlgorithm = algorithm;
		} else {
			signatureAlgorithm = keyType;
		}
		if (!open(debugging)) {
			return null;
		}
		int flags = 0;
		switch (signatureAlgorithm) {
		case KeyUtils.RSA_SHA512_KEY_TYPE_ALIAS:
		case KeyUtils.RSA_SHA512_CERT_TYPE_ALIAS:
			flags = 4;
			break;
		case KeyUtils.RSA_SHA256_KEY_TYPE_ALIAS:
		case KeyUtils.RSA_SHA256_CERT_TYPE_ALIAS:
			flags = 2;
			break;
		default:
			break;
		}
		ByteArrayBuffer msg = new ByteArrayBuffer();
		msg.putInt(0);
		msg.putByte(SshAgentConstants.SSH2_AGENTC_SIGN_REQUEST);
		msg.putPublicKey(key);
		msg.putBytes(data);
		msg.putInt(flags);
		if (debugging) {
			LOG.debug(
					"sign({}): signing request to SSH agent for {} key, {} signature; flags={}", //$NON-NLS-1$
					session, keyType, signatureAlgorithm,
					Integer.toString(flags));
		}
		Buffer reply = rpc(SshAgentConstants.SSH2_AGENTC_SIGN_REQUEST,
				msg.getCompactData());
		byte cmd = reply.getByte();
		if (cmd != SshAgentConstants.SSH2_AGENT_SIGN_RESPONSE) {
			throw new SshException(
					MessageFormat.format(SshdText.get().sshAgentReplyUnexpected,
							SshAgentConstants.getCommandMessageName(cmd)));
		}
		try {
			Buffer signatureReply = new ByteArrayBuffer(reply.getBytes());
			String actualAlgorithm = signatureReply.getString();
			byte[] signature = signatureReply.getBytes();
			if (LOG.isTraceEnabled()) {
				LOG.trace(
						"sign({}): signature reply from SSH agent for {} key: {} signature={}", //$NON-NLS-1$
						session, keyType, actualAlgorithm,
						BufferUtils.toHex(':', signature));

			} else if (LOG.isDebugEnabled()) {
				LOG.debug(
						"sign({}): signature reply from SSH agent for {} key, {} signature", //$NON-NLS-1$
						session, keyType, actualAlgorithm);
			}
			return new AbstractMap.SimpleImmutableEntry<>(actualAlgorithm,
					signature);
		} catch (BufferException e) {
			throw new SshException(SshdText.get().sshAgentShortReadBuffer, e);
		}
	}

	@Override
	public void addIdentity(KeyPair key, String comment,
			SshAgentKeyConstraint... constraints) throws IOException {
		boolean debugging = LOG.isDebugEnabled();
		if (!open(debugging)) {
			return;
		}

		// Neither Pageant 0.76 nor Win32-OpenSSH 8.6 support command
		// SSH2_AGENTC_ADD_ID_CONSTRAINED. Adding a key with constraints will
		// fail. The only work-around for users is not to use "confirm" or "time
		// spec" with AddKeysToAgent, and not to use sk-* keys.
		//
		// With a true OpenSSH SSH agent, key constraints work.
		byte cmd = (constraints != null && constraints.length > 0)
				? SshAgentConstants.SSH2_AGENTC_ADD_ID_CONSTRAINED
				: SshAgentConstants.SSH2_AGENTC_ADD_IDENTITY;
		byte[] message = null;
		ByteArrayBuffer msg = new ByteArrayBuffer();
		try {
			msg.putInt(0);
			msg.putByte(cmd);
			String keyType = KeyUtils.getKeyType(key);
			if (KeyPairProvider.SSH_ED25519.equals(keyType)) {
				// Apache MINA sshd 2.8.0 lacks support for writing ed25519
				// private keys to a buffer.
				putEd25519Key(msg, key);
			} else {
				msg.putKeyPair(key);
			}
			msg.putString(comment == null ? "" : comment); //$NON-NLS-1$
			if (constraints != null) {
				for (SshAgentKeyConstraint constraint : constraints) {
					constraint.put(msg);
				}
			}
			if (debugging) {
				LOG.debug(
						"addIdentity: adding {} key {} to SSH agent; comment {}", //$NON-NLS-1$
						keyType, KeyUtils.getFingerPrint(key.getPublic()),
						comment);
			}
			message = msg.getCompactData();
		} finally {
			// The message contains the private key data, so clear intermediary
			// data ASAP.
			msg.clear();
		}
		Buffer reply;
		try {
			reply = rpc(cmd, message);
		} finally {
			Arrays.fill(message, (byte) 0);
		}
		int replyLength = reply.available();
		if (replyLength != 1) {
			throw new SshException(MessageFormat.format(
					SshdText.get().sshAgentReplyUnexpected,
					MessageFormat.format(
							SshdText.get().sshAgentPayloadLengthError,
							Integer.valueOf(1), Integer.valueOf(replyLength))));

		}
		cmd = reply.getByte();
		if (cmd != SshAgentConstants.SSH_AGENT_SUCCESS) {
			throw new SshException(
					MessageFormat.format(SshdText.get().sshAgentReplyUnexpected,
							SshAgentConstants.getCommandMessageName(cmd)));
		}
	}

	/**
	 * Writes an ed25519 {@link KeyPair} to a {@link Buffer}. OpenSSH specifies
	 * that it expects the 32 public key bytes, followed by 64 bytes formed by
	 * concatenating the 32 private key bytes with the 32 public key bytes.
	 *
	 * @param msg
	 *            {@link Buffer} to write to
	 * @param key
	 *            {@link KeyPair} to write
	 * @throws IOException
	 *             if the private key cannot be written
	 */
	private static void putEd25519Key(Buffer msg, KeyPair key)
			throws IOException {
		Buffer tmp = new ByteArrayBuffer(36);
		tmp.putRawPublicKeyBytes(key.getPublic());
		byte[] publicBytes = tmp.getBytes();
		msg.putString(KeyPairProvider.SSH_ED25519);
		msg.putBytes(publicBytes);
		// Next is the concatenation of the 32 byte private key value with the
		// 32 bytes of the public key.
		PrivateKey pk = key.getPrivate();
		String format = pk.getFormat();
		if (!"PKCS#8".equalsIgnoreCase(format)) { //$NON-NLS-1$
			throw new IOException(MessageFormat
					.format(SshdText.get().sshAgentEdDSAFormatError, format));
		}
		byte[] privateBytes = null;
		byte[] encoded = pk.getEncoded();
		try {
			privateBytes = asn1Parse(encoded, 32);
			byte[] combined = Arrays.copyOf(privateBytes, 64);
			Arrays.fill(privateBytes, (byte) 0);
			privateBytes = combined;
			System.arraycopy(publicBytes, 0, privateBytes, 32, 32);
			msg.putBytes(privateBytes);
		} finally {
			if (privateBytes != null) {
				Arrays.fill(privateBytes, (byte) 0);
			}
			Arrays.fill(encoded, (byte) 0);
		}
	}

	/**
	 * Extracts the private key bytes from an encoded ed25519 private key by
	 * parsing the bytes as ASN.1 according to RFC 5958 (PKCS #8 encoding):
	 *
	 * <pre>
	 * OneAsymmetricKey ::= SEQUENCE {
	 *   version Version,
	 *   privateKeyAlgorithm PrivateKeyAlgorithmIdentifier,
	 *   privateKey PrivateKey,
	 *   ...
	 * }
	 *
	 * Version ::= INTEGER
	 * PrivateKeyAlgorithmIdentifier ::= AlgorithmIdentifier
	 * PrivateKey ::= OCTET STRING
	 *
	 * AlgorithmIdentifier  ::=  SEQUENCE  {
	 *   algorithm   OBJECT IDENTIFIER,
	 *   parameters  ANY DEFINED BY algorithm OPTIONAL
	 * }
	 * </pre>
	 * <p>
	 * and RFC 8410: "... when encoding a OneAsymmetricKey object, the private
	 * key is wrapped in a CurvePrivateKey object and wrapped by the OCTET
	 * STRING of the 'privateKey' field."
	 * </p>
	 *
	 * <pre>
	 * CurvePrivateKey ::= OCTET STRING
	 * </pre>
	 *
	 * @param encoded
	 *            encoded private key to extract the private key bytes from
	 * @param n
	 *            number of bytes expected
	 * @return the extracted private key bytes; of length {@code n}
	 * @throws IOException
	 *             if the private key cannot be extracted
	 * @see <a href="https://tools.ietf.org/html/rfc5958">RFC 5958</a>
	 * @see <a href="https://tools.ietf.org/html/rfc8410">RFC 8410</a>
	 */
	private static byte[] asn1Parse(byte[] encoded, int n) throws IOException {
		byte[] privateKey = null;
		try (DERParser byteParser = new DERParser(encoded);
				DERParser oneAsymmetricKey = byteParser.readObject()
						.createParser()) {
			oneAsymmetricKey.readObject(); // skip version
			oneAsymmetricKey.readObject(); // skip algorithm identifier
			privateKey = oneAsymmetricKey.readObject().getValue();
			// The last n bytes of this must be the private key bytes
			return Arrays.copyOfRange(privateKey,
					privateKey.length - n, privateKey.length);
		} finally {
			if (privateKey != null) {
				Arrays.fill(privateKey, (byte) 0);
			}
		}
	}

	/**
	 * A safe version of {@link Buffer#getPublicKey()}. Upon return the
	 * buffers's read position is always after the key blob; any exceptions
	 * thrown by trying to read the key are logged and <em>not</em> propagated.
	 * <p>
	 * This is needed because an SSH agent might contain and deliver keys that
	 * we cannot handle (for instance ed448 keys).
	 * </p>
	 *
	 * @param buffer
	 *            to read the key from
	 * @return the {@link PublicKey}, or {@code null} if the key could not be
	 *         read
	 * @throws BufferException
	 *             if the length of the key blob cannot be read or is corrupted
	 */
	private static PublicKey readKey(Buffer buffer) throws BufferException {
		int endOfBuffer = buffer.wpos();
		int keyLength = buffer.getInt();
		int afterKey = buffer.rpos() + keyLength;
		if (keyLength <= 0 || afterKey > endOfBuffer) {
			throw new BufferException(
					MessageFormat.format(SshdText.get().sshAgentWrongKeyLength,
							Integer.toString(keyLength),
							Integer.toString(buffer.rpos()),
							Integer.toString(endOfBuffer)));
		}
		// Limit subsequent reads to the public key blob
		buffer.wpos(afterKey);
		try {
			return buffer.getRawPublicKey(BufferPublicKeyParser.DEFAULT);
		} catch (Exception e) {
			LOG.warn(SshdText.get().sshAgentUnknownKey, e);
			return null;
		} finally {
			// Restore real buffer end
			buffer.wpos(endOfBuffer);
			// Set the read position to after this key, even if failed
			buffer.rpos(afterKey);
		}
	}

	private Buffer rpc(byte command, byte[] message) throws IOException {
		return new ByteArrayBuffer(connector.rpc(command, message));
	}

	private Buffer rpc(byte command) throws IOException {
		return new ByteArrayBuffer(connector.rpc(command));
	}

	@Override
	public boolean isOpen() {
		return !closed.get();
	}

	@Override
	public void removeIdentity(PublicKey key) throws IOException {
		throw new UnsupportedOperationException();
	}

	@Override
	public void removeAllIdentities() throws IOException {
		throw new UnsupportedOperationException();
	}
}