| /* |
| * 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(); |
| } |
| } |