View Javadoc
1   /*
2    * Copyright (C) 2021, Thomas Wolf <thomas.wolf@paranor.ch> and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  package org.eclipse.jgit.internal.transport.sshd.agent;
11  
12  import java.io.IOException;
13  import java.security.KeyPair;
14  import java.security.PrivateKey;
15  import java.security.PublicKey;
16  import java.text.MessageFormat;
17  import java.util.AbstractMap;
18  import java.util.ArrayList;
19  import java.util.Arrays;
20  import java.util.Collections;
21  import java.util.List;
22  import java.util.Map;
23  import java.util.concurrent.atomic.AtomicBoolean;
24  
25  import org.apache.sshd.agent.SshAgent;
26  import org.apache.sshd.agent.SshAgentConstants;
27  import org.apache.sshd.agent.SshAgentKeyConstraint;
28  import org.apache.sshd.common.SshException;
29  import org.apache.sshd.common.config.keys.KeyUtils;
30  import org.apache.sshd.common.keyprovider.KeyPairProvider;
31  import org.apache.sshd.common.session.SessionContext;
32  import org.apache.sshd.common.util.buffer.Buffer;
33  import org.apache.sshd.common.util.buffer.BufferException;
34  import org.apache.sshd.common.util.buffer.BufferUtils;
35  import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
36  import org.apache.sshd.common.util.buffer.keys.BufferPublicKeyParser;
37  import org.apache.sshd.common.util.io.der.DERParser;
38  import org.eclipse.jgit.internal.transport.sshd.SshdText;
39  import org.eclipse.jgit.transport.sshd.agent.Connector;
40  import org.slf4j.Logger;
41  import org.slf4j.LoggerFactory;
42  
43  /**
44   * A client for an SSH2 agent. This client supports querying identities,
45   * signature requests, and adding keys to an agent (with or without
46   * constraints). Removing keys is not supported, and the older SSH1 protocol is
47   * not supported.
48   *
49   * @see <a href="https://tools.ietf.org/html/draft-miller-ssh-agent-04">SSH
50   *      Agent Protocol, RFC draft</a>
51   */
52  public class SshAgentClient implements SshAgent {
53  
54  	private static final Logger LOG = LoggerFactory
55  			.getLogger(SshAgentClient.class);
56  
57  	// OpenSSH limit
58  	private static final int MAX_NUMBER_OF_KEYS = 2048;
59  
60  	private final AtomicBoolean closed = new AtomicBoolean();
61  
62  	private final Connector connector;
63  
64  	/**
65  	 * Creates a new {@link SshAgentClient} implementing the SSH2 ssh agent
66  	 * protocol, using the given {@link Connector} to connect to the SSH agent
67  	 * and to exchange messages.
68  	 *
69  	 * @param connector
70  	 *            {@link Connector} to use
71  	 */
72  	public SshAgentClient(Connector connector) {
73  		this.connector = connector;
74  	}
75  
76  	private boolean open(boolean debugging) throws IOException {
77  		if (closed.get()) {
78  			if (debugging) {
79  				LOG.debug("SSH agent connection already closed"); //$NON-NLS-1$
80  			}
81  			return false;
82  		}
83  		boolean connected;
84  		try {
85  			connected = connector != null && connector.connect();
86  			if (!connected && debugging) {
87  				LOG.debug("No SSH agent"); //$NON-NLS-1$
88  			}
89  		} catch (IOException e) {
90  			// Agent not running?
91  			if (debugging) {
92  				LOG.debug("No SSH agent", e); //$NON-NLS-1$
93  			}
94  			throw e;
95  		}
96  		return connected;
97  	}
98  
99  	@Override
100 	public void close() throws IOException {
101 		if (!closed.getAndSet(true) && connector != null) {
102 			connector.close();
103 		}
104 	}
105 
106 	@Override
107 	public Iterable<? extends Map.Entry<PublicKey, String>> getIdentities()
108 			throws IOException {
109 		boolean debugging = LOG.isDebugEnabled();
110 		if (!open(debugging)) {
111 			return Collections.emptyList();
112 		}
113 		if (debugging) {
114 			LOG.debug("Requesting identities from SSH agent"); //$NON-NLS-1$
115 		}
116 		try {
117 			Buffer reply = rpc(
118 					SshAgentConstants.SSH2_AGENTC_REQUEST_IDENTITIES);
119 			byte cmd = reply.getByte();
120 			if (cmd != SshAgentConstants.SSH2_AGENT_IDENTITIES_ANSWER) {
121 				throw new SshException(MessageFormat.format(
122 						SshdText.get().sshAgentReplyUnexpected,
123 						SshAgentConstants.getCommandMessageName(cmd)));
124 			}
125 			int numberOfKeys = reply.getInt();
126 			if (numberOfKeys < 0 || numberOfKeys > MAX_NUMBER_OF_KEYS) {
127 				throw new SshException(MessageFormat.format(
128 						SshdText.get().sshAgentWrongNumberOfKeys,
129 						Integer.toString(numberOfKeys)));
130 			}
131 			if (numberOfKeys == 0) {
132 				if (debugging) {
133 					LOG.debug("SSH agent has no keys"); //$NON-NLS-1$
134 				}
135 				return Collections.emptyList();
136 			}
137 			if (debugging) {
138 				LOG.debug("Got {} key(s) from the SSH agent", //$NON-NLS-1$
139 						Integer.toString(numberOfKeys));
140 			}
141 			boolean tracing = LOG.isTraceEnabled();
142 			List<Map.Entry<PublicKey, String>> keys = new ArrayList<>(
143 					numberOfKeys);
144 			for (int i = 0; i < numberOfKeys; i++) {
145 				PublicKey key = readKey(reply);
146 				String comment = reply.getString();
147 				if (key != null) {
148 					if (tracing) {
149 						LOG.trace("Got SSH agent {} key: {} {}", //$NON-NLS-1$
150 								KeyUtils.getKeyType(key),
151 								KeyUtils.getFingerPrint(key), comment);
152 					}
153 					keys.add(new AbstractMap.SimpleImmutableEntry<>(key,
154 							comment));
155 				}
156 			}
157 			return keys;
158 		} catch (BufferException e) {
159 			throw new SshException(SshdText.get().sshAgentShortReadBuffer, e);
160 		}
161 	}
162 
163 	@Override
164 	public Map.Entry<String, byte[]> sign(SessionContext session, PublicKey key,
165 			String algorithm, byte[] data) throws IOException {
166 		boolean debugging = LOG.isDebugEnabled();
167 		String keyType = KeyUtils.getKeyType(key);
168 		String signatureAlgorithm;
169 		if (algorithm != null) {
170 			if (!KeyUtils.getCanonicalKeyType(algorithm).equals(keyType)) {
171 				throw new IllegalArgumentException(MessageFormat.format(
172 						SshdText.get().invalidSignatureAlgorithm, algorithm,
173 						keyType));
174 			}
175 			signatureAlgorithm = algorithm;
176 		} else {
177 			signatureAlgorithm = keyType;
178 		}
179 		if (!open(debugging)) {
180 			return null;
181 		}
182 		int flags = 0;
183 		switch (signatureAlgorithm) {
184 		case KeyUtils.RSA_SHA512_KEY_TYPE_ALIAS:
185 		case KeyUtils.RSA_SHA512_CERT_TYPE_ALIAS:
186 			flags = 4;
187 			break;
188 		case KeyUtils.RSA_SHA256_KEY_TYPE_ALIAS:
189 		case KeyUtils.RSA_SHA256_CERT_TYPE_ALIAS:
190 			flags = 2;
191 			break;
192 		default:
193 			break;
194 		}
195 		ByteArrayBuffer msg = new ByteArrayBuffer();
196 		msg.putInt(0);
197 		msg.putByte(SshAgentConstants.SSH2_AGENTC_SIGN_REQUEST);
198 		msg.putPublicKey(key);
199 		msg.putBytes(data);
200 		msg.putInt(flags);
201 		if (debugging) {
202 			LOG.debug(
203 					"sign({}): signing request to SSH agent for {} key, {} signature; flags={}", //$NON-NLS-1$
204 					session, keyType, signatureAlgorithm,
205 					Integer.toString(flags));
206 		}
207 		Buffer reply = rpc(SshAgentConstants.SSH2_AGENTC_SIGN_REQUEST,
208 				msg.getCompactData());
209 		byte cmd = reply.getByte();
210 		if (cmd != SshAgentConstants.SSH2_AGENT_SIGN_RESPONSE) {
211 			throw new SshException(
212 					MessageFormat.format(SshdText.get().sshAgentReplyUnexpected,
213 							SshAgentConstants.getCommandMessageName(cmd)));
214 		}
215 		try {
216 			Buffer signatureReply = new ByteArrayBuffer(reply.getBytes());
217 			String actualAlgorithm = signatureReply.getString();
218 			byte[] signature = signatureReply.getBytes();
219 			if (LOG.isTraceEnabled()) {
220 				LOG.trace(
221 						"sign({}): signature reply from SSH agent for {} key: {} signature={}", //$NON-NLS-1$
222 						session, keyType, actualAlgorithm,
223 						BufferUtils.toHex(':', signature));
224 
225 			} else if (LOG.isDebugEnabled()) {
226 				LOG.debug(
227 						"sign({}): signature reply from SSH agent for {} key, {} signature", //$NON-NLS-1$
228 						session, keyType, actualAlgorithm);
229 			}
230 			return new AbstractMap.SimpleImmutableEntry<>(actualAlgorithm,
231 					signature);
232 		} catch (BufferException e) {
233 			throw new SshException(SshdText.get().sshAgentShortReadBuffer, e);
234 		}
235 	}
236 
237 	@Override
238 	public void addIdentity(KeyPair key, String comment,
239 			SshAgentKeyConstraint... constraints) throws IOException {
240 		boolean debugging = LOG.isDebugEnabled();
241 		if (!open(debugging)) {
242 			return;
243 		}
244 
245 		// Neither Pageant 0.76 nor Win32-OpenSSH 8.6 support command
246 		// SSH2_AGENTC_ADD_ID_CONSTRAINED. Adding a key with constraints will
247 		// fail. The only work-around for users is not to use "confirm" or "time
248 		// spec" with AddKeysToAgent, and not to use sk-* keys.
249 		//
250 		// With a true OpenSSH SSH agent, key constraints work.
251 		byte cmd = (constraints != null && constraints.length > 0)
252 				? SshAgentConstants.SSH2_AGENTC_ADD_ID_CONSTRAINED
253 				: SshAgentConstants.SSH2_AGENTC_ADD_IDENTITY;
254 		byte[] message = null;
255 		ByteArrayBuffer msg = new ByteArrayBuffer();
256 		try {
257 			msg.putInt(0);
258 			msg.putByte(cmd);
259 			String keyType = KeyUtils.getKeyType(key);
260 			if (KeyPairProvider.SSH_ED25519.equals(keyType)) {
261 				// Apache MINA sshd 2.8.0 lacks support for writing ed25519
262 				// private keys to a buffer.
263 				putEd25519Key(msg, key);
264 			} else {
265 				msg.putKeyPair(key);
266 			}
267 			msg.putString(comment == null ? "" : comment); //$NON-NLS-1$
268 			if (constraints != null) {
269 				for (SshAgentKeyConstraint constraint : constraints) {
270 					constraint.put(msg);
271 				}
272 			}
273 			if (debugging) {
274 				LOG.debug(
275 						"addIdentity: adding {} key {} to SSH agent; comment {}", //$NON-NLS-1$
276 						keyType, KeyUtils.getFingerPrint(key.getPublic()),
277 						comment);
278 			}
279 			message = msg.getCompactData();
280 		} finally {
281 			// The message contains the private key data, so clear intermediary
282 			// data ASAP.
283 			msg.clear();
284 		}
285 		Buffer reply;
286 		try {
287 			reply = rpc(cmd, message);
288 		} finally {
289 			Arrays.fill(message, (byte) 0);
290 		}
291 		int replyLength = reply.available();
292 		if (replyLength != 1) {
293 			throw new SshException(MessageFormat.format(
294 					SshdText.get().sshAgentReplyUnexpected,
295 					MessageFormat.format(
296 							SshdText.get().sshAgentPayloadLengthError,
297 							Integer.valueOf(1), Integer.valueOf(replyLength))));
298 
299 		}
300 		cmd = reply.getByte();
301 		if (cmd != SshAgentConstants.SSH_AGENT_SUCCESS) {
302 			throw new SshException(
303 					MessageFormat.format(SshdText.get().sshAgentReplyUnexpected,
304 							SshAgentConstants.getCommandMessageName(cmd)));
305 		}
306 	}
307 
308 	/**
309 	 * Writes an ed25519 {@link KeyPair} to a {@link Buffer}. OpenSSH specifies
310 	 * that it expects the 32 public key bytes, followed by 64 bytes formed by
311 	 * concatenating the 32 private key bytes with the 32 public key bytes.
312 	 *
313 	 * @param msg
314 	 *            {@link Buffer} to write to
315 	 * @param key
316 	 *            {@link KeyPair} to write
317 	 * @throws IOException
318 	 *             if the private key cannot be written
319 	 */
320 	private static void putEd25519Key(Buffer msg, KeyPair key)
321 			throws IOException {
322 		Buffer tmp = new ByteArrayBuffer(36);
323 		tmp.putRawPublicKeyBytes(key.getPublic());
324 		byte[] publicBytes = tmp.getBytes();
325 		msg.putString(KeyPairProvider.SSH_ED25519);
326 		msg.putBytes(publicBytes);
327 		// Next is the concatenation of the 32 byte private key value with the
328 		// 32 bytes of the public key.
329 		PrivateKey pk = key.getPrivate();
330 		String format = pk.getFormat();
331 		if (!"PKCS#8".equalsIgnoreCase(format)) { //$NON-NLS-1$
332 			throw new IOException(MessageFormat
333 					.format(SshdText.get().sshAgentEdDSAFormatError, format));
334 		}
335 		byte[] privateBytes = null;
336 		byte[] encoded = pk.getEncoded();
337 		try {
338 			privateBytes = asn1Parse(encoded, 32);
339 			byte[] combined = Arrays.copyOf(privateBytes, 64);
340 			Arrays.fill(privateBytes, (byte) 0);
341 			privateBytes = combined;
342 			System.arraycopy(publicBytes, 0, privateBytes, 32, 32);
343 			msg.putBytes(privateBytes);
344 		} finally {
345 			if (privateBytes != null) {
346 				Arrays.fill(privateBytes, (byte) 0);
347 			}
348 			Arrays.fill(encoded, (byte) 0);
349 		}
350 	}
351 
352 	/**
353 	 * Extracts the private key bytes from an encoded ed25519 private key by
354 	 * parsing the bytes as ASN.1 according to RFC 5958 (PKCS #8 encoding):
355 	 *
356 	 * <pre>
357 	 * OneAsymmetricKey ::= SEQUENCE {
358 	 *   version Version,
359 	 *   privateKeyAlgorithm PrivateKeyAlgorithmIdentifier,
360 	 *   privateKey PrivateKey,
361 	 *   ...
362 	 * }
363 	 *
364 	 * Version ::= INTEGER
365 	 * PrivateKeyAlgorithmIdentifier ::= AlgorithmIdentifier
366 	 * PrivateKey ::= OCTET STRING
367 	 *
368 	 * AlgorithmIdentifier  ::=  SEQUENCE  {
369 	 *   algorithm   OBJECT IDENTIFIER,
370 	 *   parameters  ANY DEFINED BY algorithm OPTIONAL
371 	 * }
372 	 * </pre>
373 	 * <p>
374 	 * and RFC 8410: "... when encoding a OneAsymmetricKey object, the private
375 	 * key is wrapped in a CurvePrivateKey object and wrapped by the OCTET
376 	 * STRING of the 'privateKey' field."
377 	 * </p>
378 	 *
379 	 * <pre>
380 	 * CurvePrivateKey ::= OCTET STRING
381 	 * </pre>
382 	 *
383 	 * @param encoded
384 	 *            encoded private key to extract the private key bytes from
385 	 * @param n
386 	 *            number of bytes expected
387 	 * @return the extracted private key bytes; of length {@code n}
388 	 * @throws IOException
389 	 *             if the private key cannot be extracted
390 	 * @see <a href="https://tools.ietf.org/html/rfc5958">RFC 5958</a>
391 	 * @see <a href="https://tools.ietf.org/html/rfc8410">RFC 8410</a>
392 	 */
393 	private static byte[] asn1Parse(byte[] encoded, int n) throws IOException {
394 		byte[] privateKey = null;
395 		try (DERParser byteParser = new DERParser(encoded);
396 				DERParser oneAsymmetricKey = byteParser.readObject()
397 						.createParser()) {
398 			oneAsymmetricKey.readObject(); // skip version
399 			oneAsymmetricKey.readObject(); // skip algorithm identifier
400 			privateKey = oneAsymmetricKey.readObject().getValue();
401 			// The last n bytes of this must be the private key bytes
402 			return Arrays.copyOfRange(privateKey,
403 					privateKey.length - n, privateKey.length);
404 		} finally {
405 			if (privateKey != null) {
406 				Arrays.fill(privateKey, (byte) 0);
407 			}
408 		}
409 	}
410 
411 	/**
412 	 * A safe version of {@link Buffer#getPublicKey()}. Upon return the
413 	 * buffers's read position is always after the key blob; any exceptions
414 	 * thrown by trying to read the key are logged and <em>not</em> propagated.
415 	 * <p>
416 	 * This is needed because an SSH agent might contain and deliver keys that
417 	 * we cannot handle (for instance ed448 keys).
418 	 * </p>
419 	 *
420 	 * @param buffer
421 	 *            to read the key from
422 	 * @return the {@link PublicKey}, or {@code null} if the key could not be
423 	 *         read
424 	 * @throws BufferException
425 	 *             if the length of the key blob cannot be read or is corrupted
426 	 */
427 	private static PublicKey readKey(Buffer buffer) throws BufferException {
428 		int endOfBuffer = buffer.wpos();
429 		int keyLength = buffer.getInt();
430 		if (keyLength <= 0 || keyLength > buffer.available()) {
431 			throw new BufferException(
432 					MessageFormat.format(SshdText.get().sshAgentWrongKeyLength,
433 							Integer.toString(keyLength),
434 							Integer.toString(buffer.rpos()),
435 							Integer.toString(endOfBuffer)));
436 		}
437 		int afterKey = buffer.rpos() + keyLength;
438 		// Limit subsequent reads to the public key blob
439 		buffer.wpos(afterKey);
440 		try {
441 			return buffer.getRawPublicKey(BufferPublicKeyParser.DEFAULT);
442 		} catch (Exception e) {
443 			LOG.warn(SshdText.get().sshAgentUnknownKey, e);
444 			return null;
445 		} finally {
446 			// Restore real buffer end
447 			buffer.wpos(endOfBuffer);
448 			// Set the read position to after this key, even if failed
449 			buffer.rpos(afterKey);
450 		}
451 	}
452 
453 	private Buffer rpc(byte command, byte[] message) throws IOException {
454 		return new ByteArrayBuffer(connector.rpc(command, message));
455 	}
456 
457 	private Buffer rpc(byte command) throws IOException {
458 		return new ByteArrayBuffer(connector.rpc(command));
459 	}
460 
461 	@Override
462 	public boolean isOpen() {
463 		return !closed.get();
464 	}
465 
466 	@Override
467 	public void removeIdentity(PublicKey key) throws IOException {
468 		throw new UnsupportedOperationException();
469 	}
470 
471 	@Override
472 	public void removeAllIdentities() throws IOException {
473 		throw new UnsupportedOperationException();
474 	}
475 }