blob: cbcb4d240ed9d9ca2d6f2cb06d78ca8297f63f6e [file] [log] [blame]
/*
* 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();
}
}