diff options
author | Thomas Wolf <twolf@apache.org> | 2023-07-09 20:06:37 +0200 |
---|---|---|
committer | Thomas Wolf <twolf@apache.org> | 2023-07-17 04:52:30 -0400 |
commit | 23758d7a61081be4e28c9fc2a0256d7774962455 (patch) | |
tree | 9a661f915f0ad12d77d0e495f73c4b1a3c224ea1 /org.eclipse.jgit.ssh.apache/src/org | |
parent | 760bdd09b1d186d4ca4f21b7f771882513521949 (diff) | |
download | jgit-23758d7a61081be4e28c9fc2a0256d7774962455.tar.gz jgit-23758d7a61081be4e28c9fc2a0256d7774962455.zip |
ssh: PKCS#11 support
Support PKCS#11 HSMs (like YubiKey PIV) for SSH authentication.
Use the SunPKCS11 provider as described at [1]. This provider
dynamically loads the library from the PKCS11Provider SSH configuration
and creates a Java KeyStore with that provider. A Java CallbackHandler
is needed to feed PIN prompts from the KeyStore into the JGit
CredentialsProvider framework. Because the JGit CredentialsProvider may
be specific to a SSH session but the PKCS11Provider may be used by
several sessions, the CallbackHandler needs to be configurable per
session.
PIN prompts respect the NumberOfPasswordPrompts SSH configuration. As
long as the library asks only for a PIN, we use the KeyPasswordProvider
to prompt for it. This gives automatic integration in Eclipse with the
Eclipse secure storage, so a user has even the option to store the PIN
there. (Eclipse will then ask for the secure storage master password on
first access, so the usefulness of this is debatable.)
By default the provider uses the first PKCS#11 token (slot list index
zero). This can be overridden by a non-standard PKCS11SlotListIndex
ssh configuration entry. (For OpenSSH interoperability, also set
"IgnoreUnknown PKCS11SlotListIndex" in the SSH config file then.)
Once loaded, the provider and its shared library and the keys
contained remain available until the application exits.
Manually tested using SoftHSM. See file manual_tests.txt. Kudos to
Christopher Lamb for additional manual testing with a real YubiKey,
also on Windows.[2]
[1] https://docs.oracle.com/en/java/javase/11/security/pkcs11-reference-guide1.html
[2] https://www.eclipse.org/forums/index.php/t/1113295/
Change-Id: I544c97e1e24d05e28a9f0e803fd4b9151a76ed11
Signed-off-by: Thomas Wolf <twolf@apache.org>
Diffstat (limited to 'org.eclipse.jgit.ssh.apache/src/org')
7 files changed, 844 insertions, 28 deletions
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java index 76175cc5b8..c19a04d7e5 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import org.apache.sshd.client.ClientBuilder; import org.apache.sshd.client.ClientFactoryManager; @@ -55,6 +56,7 @@ import org.eclipse.jgit.fnmatch.FileNameMatcher; import org.eclipse.jgit.internal.transport.sshd.proxy.StatefulProxyConnector; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; +import org.eclipse.jgit.transport.sshd.KeyPasswordProvider; import org.eclipse.jgit.util.StringUtils; /** @@ -69,6 +71,12 @@ import org.eclipse.jgit.util.StringUtils; public class JGitClientSession extends ClientSessionImpl { /** + * Attribute set by {@link JGitSshClient} to make the + * {@link KeyPasswordProvider} factory accessible via the session. + */ + public static final AttributeKey<Supplier<KeyPasswordProvider>> KEY_PASSWORD_PROVIDER_FACTORY = new AttributeKey<>(); + + /** * Default setting for the maximum number of bytes to read in the initial * protocol version exchange. 64kb is what OpenSSH < 8.0 read; OpenSSH * 8.0 changed it to 8Mb, but that seems excessive for the purpose stated in diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java index e2da7991af..9f1df89e33 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others + * Copyright (C) 2018, 2023 Thomas Wolf <twolf@apache.org> 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 @@ -10,8 +10,12 @@ package org.eclipse.jgit.internal.transport.sshd; import static java.text.MessageFormat.format; +import static org.eclipse.jgit.transport.SshConstants.NONE; +import static org.eclipse.jgit.transport.SshConstants.PKCS11_PROVIDER; +import static org.eclipse.jgit.transport.SshConstants.PKCS11_SLOT_LIST_INDEX; import static org.eclipse.jgit.transport.SshConstants.PUBKEY_ACCEPTED_ALGORITHMS; +import java.io.File; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; @@ -25,6 +29,7 @@ import java.security.PublicKey; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -49,19 +54,25 @@ import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.common.config.keys.u2f.SecurityKeyPublicKey; import org.apache.sshd.common.signature.Signature; import org.apache.sshd.common.signature.SignatureFactoriesManager; +import org.apache.sshd.common.util.GenericUtils; import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile; +import org.eclipse.jgit.internal.transport.sshd.pkcs11.Pkcs11Provider; import org.eclipse.jgit.transport.CredentialItem; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.URIish; +import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.StringUtils; /** * Custom {@link UserAuthPublicKey} implementation for handling SSH config - * PubkeyAcceptedAlgorithms and interaction with the SSH agent. + * PubkeyAcceptedAlgorithms and interaction with the SSH agent and PKCS11 + * providers. */ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { + private static final String LOG_FORMAT = "{}"; //$NON-NLS-1$ + private SshAgent agent; private HostConfigEntry hostConfig; @@ -102,7 +113,7 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { super.init(session, service); return; } - log.warn(format(SshdText.get().configNoKnownAlgorithms, + log.warn(LOG_FORMAT, format(SshdText.get().configNoKnownAlgorithms, PUBKEY_ACCEPTED_ALGORITHMS, pubkeyAlgos)); } // TODO: remove this once we're on an sshd version that has SSHD-1272 @@ -181,7 +192,7 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { } catch (IOException e) { // Do not re-throw: we don't want authentication to fail if // we cannot add the key to the agent. - log.error( + log.error(LOG_FORMAT, format(SshdText.get().pubkeyAuthAddKeyToAgentError, keyType, fingerprint), e); @@ -303,13 +314,6 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { private class KeyIterator extends UserAuthPublicKeyIterator { - private Iterable<? extends Map.Entry<PublicKey, String>> agentKeys; - - // If non-null, all the public keys from explicitly given key files. Any - // agent key not matching one of these public keys will be ignored in - // getIdentities(). - private Collection<PublicKey> identityFiles; - public KeyIterator(ClientSession session, SignatureFactoriesManager manager) throws Exception { @@ -331,7 +335,8 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { } } catch (InvalidPathException | IOException | GeneralSecurityException e) { - log.warn(format(SshdText.get().cannotReadPublicKey, s), e); + log.warn("{}", //$NON-NLS-1$ + format(SshdText.get().cannotReadPublicKey, s), e); } return null; }).filter(Objects::nonNull).collect(Collectors.toList()); @@ -340,36 +345,40 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { @Override protected Iterable<KeyAgentIdentity> initializeAgentIdentities( ClientSession session) throws IOException { - if (agent == null) { + Iterable<KeyAgentIdentity> allAgentKeys = getAgentIdentities(); + if (allAgentKeys == null) { return null; } - agentKeys = agent.getIdentities(); - if (hostConfig != null && hostConfig.isIdentitiesOnly()) { - identityFiles = getExplicitKeys(hostConfig.getIdentities()); + Collection<PublicKey> identityFiles = identitiesOnly(); + if (GenericUtils.isEmpty(identityFiles)) { + return allAgentKeys; } + + // Only consider agent or PKCS11 keys that match a known public key + // file. return () -> new Iterator<>() { - private final Iterator<? extends Map.Entry<PublicKey, String>> iter = agentKeys + private final Iterator<KeyAgentIdentity> identities = allAgentKeys .iterator(); - private Map.Entry<PublicKey, String> next; + private KeyAgentIdentity next; @Override public boolean hasNext() { - while (next == null && iter.hasNext()) { - Map.Entry<PublicKey, String> val = iter.next(); - PublicKey pk = val.getKey(); + while (next == null && identities.hasNext()) { + KeyAgentIdentity val = identities.next(); + PublicKey pk = val.getKeyIdentity().getPublic(); // This checks against all explicit keys for any agent // key, but since identityFiles.size() is typically 1, // it should be fine. - if (identityFiles == null || identityFiles.stream() + if (identityFiles.stream() .anyMatch(k -> KeyUtils.compareKeys(k, pk))) { next = val; return true; } if (log.isTraceEnabled()) { log.trace( - "Ignoring SSH agent {} key not in explicit IdentityFile in SSH config: {}", //$NON-NLS-1$ + "Ignoring SSH agent or PKCS11 {} key not in explicit IdentityFile in SSH config: {}", //$NON-NLS-1$ KeyUtils.getKeyType(pk), KeyUtils.getFingerPrint(pk)); } @@ -382,12 +391,157 @@ public class JGitPublicKeyAuthentication extends UserAuthPublicKey { if (!hasNext()) { throw new NoSuchElementException(); } - KeyAgentIdentity result = new KeyAgentIdentity(agent, - next.getKey(), next.getValue()); + KeyAgentIdentity result = next; next = null; return result; } }; } + + private Collection<PublicKey> identitiesOnly() { + if (hostConfig != null && hostConfig.isIdentitiesOnly()) { + return getExplicitKeys(hostConfig.getIdentities()); + } + return Collections.emptyList(); + } + + private Iterable<KeyAgentIdentity> getAgentIdentities() + throws IOException { + Iterable<KeyAgentIdentity> pkcs11Keys = getPkcs11Keys(); + if (agent == null) { + return pkcs11Keys; + } + Iterable<? extends Map.Entry<PublicKey, String>> agentKeys = agent + .getIdentities(); + if (GenericUtils.isEmpty(agentKeys)) { + return pkcs11Keys; + } + Iterable<KeyAgentIdentity> fromAgent = () -> new Iterator<>() { + + private final Iterator<? extends Map.Entry<PublicKey, String>> iter = agentKeys + .iterator(); + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public KeyAgentIdentity next() { + Map.Entry<PublicKey, String> next = iter.next(); + return new KeyAgentIdentity(agent, next.getKey(), + next.getValue()); + } + }; + if (GenericUtils.isEmpty(pkcs11Keys)) { + return fromAgent; + } + return () -> new Iterator<>() { + + private final Iterator<Iterator<KeyAgentIdentity>> keyIter = List + .of(pkcs11Keys.iterator(), fromAgent.iterator()) + .iterator(); + + private Iterator<KeyAgentIdentity> currentKeys; + + private Boolean hasElement; + + @Override + public boolean hasNext() { + if (hasElement != null) { + return hasElement.booleanValue(); + } + while (currentKeys == null || !currentKeys.hasNext()) { + if (keyIter.hasNext()) { + currentKeys = keyIter.next(); + } else { + currentKeys = null; + hasElement = Boolean.FALSE; + return false; + } + } + hasElement = Boolean.TRUE; + return true; + } + + @Override + public KeyAgentIdentity next() { + if (hasElement == null && !hasNext() + || !hasElement.booleanValue()) { + throw new NoSuchElementException(); + } + hasElement = null; + KeyAgentIdentity result; + try { + result = currentKeys.next(); + } catch (NoSuchElementException e) { + result = null; + } + return result; + } + }; + } + + private Iterable<KeyAgentIdentity> getPkcs11Keys() throws IOException { + String value = hostConfig.getProperty(PKCS11_PROVIDER); + if (StringUtils.isEmptyOrNull(value) || NONE.equals(value)) { + return null; + } + if (value.startsWith("~/") //$NON-NLS-1$ + || value.startsWith('~' + File.separator)) { + value = new File(FS.DETECTED.userHome(), value.substring(2)) + .toString(); + } + Path library = Paths.get(value); + if (!library.isAbsolute()) { + throw new IOException(format(SshdText.get().pkcs11NotAbsolute, + hostConfig.getHost(), hostConfig.getHostName(), + PKCS11_PROVIDER, value)); + } + if (!Files.isRegularFile(library)) { + throw new IOException(format(SshdText.get().pkcs11NonExisting, + hostConfig.getHost(), hostConfig.getHostName(), + PKCS11_PROVIDER, value)); + } + try { + int slotListIndex = OpenSshConfigFile.positive( + hostConfig.getProperty(PKCS11_SLOT_LIST_INDEX)); + Pkcs11Provider provider = Pkcs11Provider.getProvider(library, + slotListIndex); + if (provider == null) { + throw new UnsupportedOperationException(); + } + Iterable<KeyAgentIdentity> pkcs11Identities = provider + .getKeys(getSession()); + if (GenericUtils.isEmpty(pkcs11Identities)) { + log.warn(LOG_FORMAT, format(SshdText.get().pkcs11NoKeys, + hostConfig.getHost(), hostConfig.getHostName(), + PKCS11_PROVIDER, value)); + return null; + } + return pkcs11Identities; + } catch (UnsupportedOperationException e) { + throw new UnsupportedOperationException(format( + SshdText.get().pkcs11Unsupported, hostConfig.getHost(), + hostConfig.getHostName(), PKCS11_PROVIDER, value), e); + } catch (Exception e) { + checkCancellation(e); + throw new IOException( + format(SshdText.get().pkcs11FailedInstantiation, + hostConfig.getHost(), hostConfig.getHostName(), + PKCS11_PROVIDER, value), + e); + } + } + + private void checkCancellation(Throwable e) { + Throwable t = e; + while (t != null) { + if (t instanceof AuthenticationCanceledException) { + throw (AuthenticationCanceledException) t; + } + t = t.getCause(); + } + } } } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java index 311cf198ae..6e9bd621d2 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Objects; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.sshd.client.SshClient; @@ -58,6 +59,7 @@ import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.sshd.KeyCache; +import org.eclipse.jgit.transport.sshd.KeyPasswordProvider; import org.eclipse.jgit.transport.sshd.ProxyData; import org.eclipse.jgit.transport.sshd.ProxyDataFactory; import org.eclipse.jgit.util.StringUtils; @@ -103,6 +105,8 @@ public class JGitSshClient extends SshClient { private CredentialsProvider credentialsProvider; + private Supplier<KeyPasswordProvider> keyPasswordProviderFactory; + private ProxyDataFactory proxyDatabase; @Override @@ -277,6 +281,8 @@ public class JGitSshClient extends SshClient { } int numberOfPasswordPrompts = getNumberOfPasswordPrompts(hostConfig); PASSWORD_PROMPTS.set(session, Integer.valueOf(numberOfPasswordPrompts)); + session.setAttribute(JGitClientSession.KEY_PASSWORD_PROVIDER_FACTORY, + getKeyPasswordProviderFactory()); List<Path> identities = hostConfig.getIdentities().stream() .map(s -> { try { @@ -374,6 +380,26 @@ public class JGitSshClient extends SshClient { } /** + * Sets a supplier for a {@link KeyPasswordProvider} for this client. + * + * @param factory + * to set + */ + public void setKeyPasswordProviderFactory( + Supplier<KeyPasswordProvider> factory) { + keyPasswordProviderFactory = factory; + } + + /** + * Retrieves the {@link KeyPasswordProvider} factory of this client. + * + * @return a factory to create {@link KeyPasswordProvider}s + */ + public Supplier<KeyPasswordProvider> getKeyPasswordProviderFactory() { + return keyPasswordProviderFactory; + } + + /** * A {@link SessionFactory} to create our own specialized * {@link JGitClientSession}s. */ diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java index 39332d9fca..34c73fcc1d 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java @@ -90,6 +90,14 @@ public final class SshdText extends TranslationBundle { /***/ public String knownHostsUserAskCreationPrompt; /***/ public String loginDenied; /***/ public String passwordPrompt; + /***/ public String pkcs11Error; + /***/ public String pkcs11FailedInstantiation; + /***/ public String pkcs11GeneralMessage; + /***/ public String pkcs11NoKeys; + /***/ public String pkcs11NonExisting; + /***/ public String pkcs11NotAbsolute; + /***/ public String pkcs11Unsupported; + /***/ public String pkcs11Warning; /***/ public String proxyCannotAuthenticate; /***/ public String proxyHttpFailure; /***/ public String proxyHttpInvalidUserName; diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/Pkcs11Provider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/Pkcs11Provider.java new file mode 100644 index 0000000000..eefa3aa868 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/Pkcs11Provider.java @@ -0,0 +1,372 @@ +/* + * Copyright (C) 2023 Thomas Wolf <twolf@apache.org> 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.pkcs11; + +import java.io.IOException; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.PublicKey; +import java.security.Security; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.security.auth.login.FailedLoginException; + +import org.apache.sshd.agent.SshAgent; +import org.apache.sshd.agent.SshAgentKeyConstraint; +import org.apache.sshd.client.auth.pubkey.KeyAgentIdentity; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.transport.URIish; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Bridge for using a PKCS11 HSM (Hardware Security Module) for public-key + * authentication. + */ +public class Pkcs11Provider { + + private static final Logger LOG = LoggerFactory + .getLogger(Pkcs11Provider.class); + + /** + * A dummy agent; exists only because + * {@link KeyAgentIdentity#KeyAgentIdentity(SshAgent, PublicKey, String)} requires + * a non-{@code null} {@link SshAgent}. + */ + private static final SshAgent NULL_AGENT = new SshAgent() { + + @Override + public boolean isOpen() { + return true; + } + + @Override + public void close() throws IOException { + // Nothing to do + } + + @Override + public Iterable<? extends Entry<PublicKey, String>> getIdentities() + throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public Entry<String, byte[]> sign(SessionContext session, PublicKey key, + String algo, byte[] data) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void addIdentity(KeyPair key, String comment, + SshAgentKeyConstraint... constraints) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void removeIdentity(PublicKey key) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void removeAllIdentities() throws IOException { + throw new UnsupportedOperationException(); + } + + }; + + private static final Map<String, Pkcs11Provider> PROVIDERS = new ConcurrentHashMap<>(); + + private static final AtomicInteger COUNT = new AtomicInteger(); + + /** + * Creates a new {@link Pkcs11Provider}. + * + * @param library + * {@link Path} to the library the SunPKCS11 provider shall use + * @param slotListIndex + * index identifying the token; if < 0, ignored and 0 is used + * @return a new {@link Pkcs11Provider}, or {@code null} if SunPKCS11 is not + * available + * @throws IOException + * if the configuration file cannot be created + * @throws java.security.ProviderException + * if the Java {@link Provider} encounters a problem + * @throws UnsupportedOperationException + * if PKCS#11 is unsupported + */ + public static Pkcs11Provider getProvider(@NonNull Path library, + int slotListIndex) throws IOException { + int slotIndex = slotListIndex < 0 ? 0 : slotListIndex; + Path libPath = library.toAbsolutePath(); + String key = libPath.toString() + '/' + slotIndex; + return PROVIDERS.computeIfAbsent(key, sharedLib -> { + Provider pkcs11 = Security.getProvider("SunPKCS11"); //$NON-NLS-1$ + if (pkcs11 == null) { + throw new UnsupportedOperationException(); + } + // There must not be any spaces in the name. + String name = libPath.getFileName().toString().replaceAll("\\s", //$NON-NLS-1$ + ""); //$NON-NLS-1$ + name = "JGit-" + slotIndex + '-' + name; //$NON-NLS-1$ + // SunPKCS11 has a problem with paths containing multiple successive + // spaces; it collapses them to a single space. + // + // However, it also performs property expansion on these paths. + // (Seems to be an undocumented feature, though.) A reference like + // ${xyz} is replaced by system property "xyz". Use that to work + // around the rudimentary config parsing in SunPKCS11. + String property = "pkcs11-" + COUNT.incrementAndGet() + '-' + name; //$NON-NLS-1$ + System.setProperty(property, libPath.toString()); + // Undocumented feature of the SunPKCS11 provider: if the parameter + // to configure() starts with two dashes, it's not a file name but + // the configuration directly. + String config = "--" //$NON-NLS-1$ + + "name = " + name + '\n' //$NON-NLS-1$ + + "library = ${" + property + "}\n" //$NON-NLS-1$ //$NON-NLS-2$ + + "slotListIndex = " + slotIndex + '\n'; //$NON-NLS-1$ + if (LOG.isDebugEnabled()) { + LOG.debug( + "{}: configuring provider with system property {}={} and config:{}{}", //$NON-NLS-1$ + name, property, libPath, System.lineSeparator(), + config); + } + pkcs11 = pkcs11.configure(config); + // Produce an RFC7512 URI. Empty path, module-path must be in + // the query. + String path = "pkcs11:?module-path=" + libPath; //$NON-NLS-1$ + if (slotListIndex > 0) { + // RFC7512 has nothing for the slot list index; pretend it + // was a vendor-specific query attribute. + path += "&slot-list-index=" + slotListIndex; //$NON-NLS-1$ + } + SecurityCallback callback = new SecurityCallback( + new URIish().setPath(path)); + return new Pkcs11Provider(pkcs11, callback); + }); + } + + private final Provider provider; + + private final SecurityCallback prompter; + + private final KeyStore.Builder builder; + + private KeyStore keys; + + private Pkcs11Provider(Provider pkcs11, SecurityCallback prompter) { + this.provider = pkcs11; + this.prompter = prompter; + this.builder = KeyStore.Builder.newInstance("PKCS11", provider, //$NON-NLS-1$ + new KeyStore.CallbackHandlerProtection(prompter)); + } + + // Implementation note: With SoftHSM Java 11 asks for the PIN when the + // KeyStore is loaded, i.e., when the token is accessed. softhsm2-util, + // however, can list certificates and public keys without PIN entry, but + // needs a PIN to also list private keys. So it appears that different + // module libraries or possibly different KeyStore implementations may + // prompt either when accessing the token, or only when we try to actually + // sign something (i.e., when accessing a private key). It may also depend + // on the token itself; some tokens require early log-in. + // + // Therefore we initialize the prompter in both cases, even if it may be + // unused in one or the other operation. + // + // The price to pay is that sign() has to be synchronized, too, to avoid + // that different sessions step on each other's toes in the prompter. + + private synchronized void load(SessionContext session) + throws GeneralSecurityException, IOException { + if (keys == null) { + int numberOfPrompts = prompter.init(session); + int attempt = 0; + while (attempt < numberOfPrompts) { + attempt++; + try { + if (LOG.isDebugEnabled()) { + LOG.debug( + "{}: Loading PKCS#11 KeyStore (attempt {})", //$NON-NLS-1$ + getName(), Integer.toString(attempt)); + } + keys = builder.getKeyStore(); + prompter.passwordTried(null); + return; + } catch (GeneralSecurityException e) { + if (!prompter.passwordTried(e) || attempt >= numberOfPrompts + || !isWrongPin(e)) { + throw e; + } + } + } + } + } + + synchronized byte[] sign(SessionContext session, String algorithm, + String alias, byte[] data) + throws GeneralSecurityException, IOException { + int numberOfPrompts = prompter.init(session); + int attempt = 0; + while (attempt < numberOfPrompts) { + attempt++; + try { + if (LOG.isDebugEnabled()) { + LOG.debug( + "{}: Signing with PKCS#11 key {}, algorithm {} (attempt {})", //$NON-NLS-1$ + getName(), alias, algorithm, + Integer.toString(attempt)); + } + Signature signer = Signature.getInstance(algorithm, provider); + PrivateKey privKey = (PrivateKey) keys.getKey(alias, null); + signer.initSign(privKey); + signer.update(data); + byte[] signature = signer.sign(); + prompter.passwordTried(null); + return signature; + } catch (GeneralSecurityException e) { + if (!prompter.passwordTried(e) || attempt >= numberOfPrompts + || !isWrongPin(e)) { + throw e; + } + } + } + return null; + } + + private boolean isWrongPin(Throwable e) { + Throwable t = e; + while (t != null) { + if (t instanceof FailedLoginException) { + return true; + } + t = t.getCause(); + } + return false; + } + + /** + * Retrieves an identifying name of this {@link Pkcs11Provider}. + * + * @return the name + */ + public String getName() { + return provider.getName(); + } + + /** + * Obtains the identities provided by the PKCS11 library. + * + * @param session + * in which we to load the identities + * @return all the available identities + * @throws IOException + * if keys cannot be accessed + * @throws GeneralSecurityException + * if keys cannot be accessed + */ + public Iterable<KeyAgentIdentity> getKeys(SessionContext session) + throws IOException, GeneralSecurityException { + // Get all public keys from the KeyStore. + load(session); + List<KeyAgentIdentity> result = new ArrayList<>(2); + Enumeration<String> aliases = keys.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + Certificate certificate = keys.getCertificate(alias); + if (certificate == null) { + continue; + } + PublicKey pubKey = certificate.getPublicKey(); + if (pubKey == null) { + // This should never happen + if (LOG.isDebugEnabled()) { + LOG.debug("{}: certificate {} has no public key??", //$NON-NLS-1$ + getName(), alias); + } + continue; + } + if (LOG.isDebugEnabled()) { + if (certificate instanceof X509Certificate) { + X509Certificate x509 = (X509Certificate) certificate; + // OpenSSH does not seem to check certificate validity? + String msg; + try { + x509.checkValidity(); + msg = "Certificate is valid"; //$NON-NLS-1$ + } catch (CertificateExpiredException + | CertificateNotYetValidException e) { + msg = "Certificate is INVALID"; //$NON-NLS-1$ + } + // OpenSSh explicitly also considers private keys not + // intended for signing, see + // https://bugzilla.mindrot.org/show_bug.cgi?id=1736 . + boolean[] usage = x509.getKeyUsage(); + if (usage != null) { + // We have no access to the PKCS#11 flags on the key, so + // report the certificate flag, if present. + msg += ", signing " //$NON-NLS-1$ + + (usage[0] ? "allowed" : "NOT allowed"); //$NON-NLS-1$ //$NON-NLS-2$ + } + LOG.debug( + "{}: Loaded X.509 certificate {}, key type {}. {}.", //$NON-NLS-1$ + getName(), alias, pubKey.getAlgorithm(), msg); + } else { + LOG.debug("{}: Loaded certificate {}, key type {}.", //$NON-NLS-1$ + getName(), alias, pubKey.getAlgorithm()); + } + } + result.add(new Pkcs11Identity(pubKey, alias)); + } + return result; + } + + // We use a KeyAgentIdentity because we want to hide the private key. + // + // JGit doesn't do Agent forwarding, so there will never be any reason to + // add a PKCS11 key/token to an agent. + private class Pkcs11Identity extends KeyAgentIdentity { + + Pkcs11Identity(PublicKey key, String alias) { + super(NULL_AGENT, key, alias); + } + + @Override + public Entry<String, byte[]> sign(SessionContext session, String algo, + byte[] data) throws Exception { + // Find the built-in signature factory for the algorithm + BuiltinSignatures factory = BuiltinSignatures.fromFactoryName(algo); + // Get its Java signature algorithm name from that + String javaSignatureName = factory.create().getAlgorithm(); + // We cannot use the Signature created by the factory -- we need a + // provider-specific Signature instance. + return new SimpleImmutableEntry<>(algo, + Pkcs11Provider.this.sign(session, javaSignatureName, + getComment(), data)); + } + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/SecurityCallback.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/SecurityCallback.java new file mode 100644 index 0000000000..334a8cac81 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/SecurityCallback.java @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2023 Thomas Wolf <twolf@apache.org> 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.pkcs11; + +import static java.text.MessageFormat.format; +import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.function.Supplier; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.ChoiceCallback; +import javax.security.auth.callback.ConfirmationCallback; +import javax.security.auth.callback.LanguageCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.TextInputCallback; +import javax.security.auth.callback.TextOutputCallback; +import javax.security.auth.callback.UnsupportedCallbackException; + +import org.apache.sshd.common.session.SessionContext; +import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException; +import org.eclipse.jgit.internal.transport.sshd.JGitClientSession; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.URIish; +import org.eclipse.jgit.transport.sshd.KeyPasswordProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A bridge to the JGit {@link CredentialsProvider}. + */ +public class SecurityCallback implements CallbackHandler { + + private static final Logger LOG = LoggerFactory + .getLogger(SecurityCallback.class); + + private final URIish uri; + + private KeyPasswordProvider passwordProvider; + + private CredentialsProvider credentialsProvider; + + private int attempts = 0; + + /** + * Creates a new {@link SecurityCallback}. + * + * @param uri + * {@link URIish} identifying the item the interaction is about + */ + public SecurityCallback(URIish uri) { + this.uri = uri; + } + + /** + * Initializes this {@link SecurityCallback} for the given session. + * + * @param session + * {@link SessionContext} of the keystore access + * @return the number of PIN prompts to try to log-in to the token + */ + public int init(SessionContext session) { + int numberOfAttempts = PASSWORD_PROMPTS.getRequired(session).intValue(); + Supplier<KeyPasswordProvider> factory = session + .getAttribute(JGitClientSession.KEY_PASSWORD_PROVIDER_FACTORY); + if (factory == null) { + passwordProvider = null; + } else { + passwordProvider = factory.get(); + passwordProvider.setAttempts(numberOfAttempts); + } + attempts = 0; + if (session instanceof JGitClientSession) { + credentialsProvider = ((JGitClientSession) session) + .getCredentialsProvider(); + } else { + credentialsProvider = null; + } + return numberOfAttempts; + } + + /** + * Tells this {@link SecurityCallback} that an attempt to load something + * from the key store has been made. + * + * @param error + * an {@link Exception} that may have occurred, or {@code null} + * on success + * @return whether to try once more + * @throws IOException + * on errors + * @throws GeneralSecurityException + * on errors + */ + public boolean passwordTried(Exception error) + throws IOException, GeneralSecurityException { + if (attempts > 0 && passwordProvider != null) { + return passwordProvider.keyLoaded(uri, attempts, error); + } + return true; + } + + @Override + public void handle(Callback[] callbacks) + throws IOException, UnsupportedCallbackException { + if (callbacks.length == 1 && callbacks[0] instanceof PasswordCallback + && passwordProvider != null) { + PasswordCallback p = (PasswordCallback) callbacks[0]; + char[] password = passwordProvider.getPassphrase(uri, attempts++); + if (password == null || password.length == 0) { + throw new AuthenticationCanceledException(); + } + p.setPassword(password); + Arrays.fill(password, '\0'); + } else { + handleGeneral(callbacks); + } + } + + private void handleGeneral(Callback[] callbacks) + throws UnsupportedCallbackException { + List<CredentialItem> items = new ArrayList<>(); + List<Runnable> updaters = new ArrayList<>(); + for (int i = 0; i < callbacks.length; i++) { + Callback c = callbacks[i]; + if (c instanceof TextOutputCallback) { + TextOutputCallback t = (TextOutputCallback) c; + String msg = getText(t.getMessageType(), t.getMessage()); + if (credentialsProvider == null) { + LOG.warn("{}", format(SshdText.get().pkcs11GeneralMessage, //$NON-NLS-1$ + uri, msg)); + } else { + CredentialItem.InformationalMessage item = + new CredentialItem.InformationalMessage(msg); + items.add(item); + } + } else if (c instanceof TextInputCallback) { + if (credentialsProvider == null) { + throw new UnsupportedOperationException( + "No CredentialsProvider " + uri); //$NON-NLS-1$ + } + TextInputCallback t = (TextInputCallback) c; + CredentialItem.StringType item = new CredentialItem.StringType( + t.getPrompt(), false); + String defaultValue = t.getDefaultText(); + if (defaultValue != null) { + item.setValue(defaultValue); + } + items.add(item); + updaters.add(() -> t.setText(item.getValue())); + } else if (c instanceof PasswordCallback) { + if (credentialsProvider == null) { + throw new UnsupportedOperationException( + "No CredentialsProvider " + uri); //$NON-NLS-1$ + } + // It appears that this is actually the only callback item we + // get from the KeyStore when it asks for the PIN. + PasswordCallback p = (PasswordCallback) c; + CredentialItem.Password item = new CredentialItem.Password( + p.getPrompt()); + items.add(item); + updaters.add(() -> { + char[] password = item.getValue(); + if (password == null || password.length == 0) { + throw new AuthenticationCanceledException(); + } + p.setPassword(password); + item.clear(); + }); + } else if (c instanceof ConfirmationCallback) { + if (credentialsProvider == null) { + throw new UnsupportedOperationException( + "No CredentialsProvider " + uri); //$NON-NLS-1$ + } + // JGit has only limited support for this + ConfirmationCallback conf = (ConfirmationCallback) c; + int options = conf.getOptionType(); + int defaultOption = conf.getDefaultOption(); + CredentialItem.YesNoType item = new CredentialItem.YesNoType( + getText(conf.getMessageType(), conf.getPrompt())); + switch (options) { + case ConfirmationCallback.YES_NO_OPTION: + if (defaultOption == ConfirmationCallback.YES) { + item.setValue(true); + } + updaters.add(() -> conf.setSelectedIndex( + item.getValue() ? ConfirmationCallback.YES + : ConfirmationCallback.NO)); + break; + case ConfirmationCallback.OK_CANCEL_OPTION: + if (defaultOption == ConfirmationCallback.OK) { + item.setValue(true); + } + updaters.add(() -> conf.setSelectedIndex( + item.getValue() ? ConfirmationCallback.OK + : ConfirmationCallback.CANCEL)); + break; + default: + throw new UnsupportedCallbackException(c); + } + items.add(item); + } else if (c instanceof ChoiceCallback) { + // TODO: implement? Information for the prompt, and individual + // YesNoItems for the choices? Might be better to hoist JGit + // onto the CallbackHandler interface directly, or add support + // for choices. + throw new UnsupportedCallbackException(c); + } else if (c instanceof LanguageCallback) { + ((LanguageCallback) c).setLocale(Locale.getDefault()); + } else { + throw new UnsupportedCallbackException(c); + } + } + if (!items.isEmpty()) { + if (credentialsProvider.get(uri, items)) { + updaters.forEach(Runnable::run); + } else { + throw new AuthenticationCanceledException(); + } + } + } + + private String getText(int messageType, String text) { + if (messageType == TextOutputCallback.WARNING) { + return format(SshdText.get().pkcs11Warning, text); + } else if (messageType == TextOutputCallback.ERROR) { + return format(SshdText.get().pkcs11Error, text); + } + return text; + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java index a99847aa91..35c9be047a 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java @@ -210,11 +210,12 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { home, sshDir); KeyIdentityProvider defaultKeysProvider = toKeyIdentityProvider( getDefaultKeys(sshDir)); + Supplier<KeyPasswordProvider> keyPasswordProvider = () -> createKeyPasswordProvider( + credentialsProvider); SshClient client = ClientBuilder.builder() .factory(JGitSshClient::new) .filePasswordProvider(createFilePasswordProvider( - () -> createKeyPasswordProvider( - credentialsProvider))) + keyPasswordProvider)) .hostConfigEntryResolver(configFile) .serverKeyVerifier(new JGitServerKeyVerifier( getServerKeyDatabase(home, sshDir))) @@ -236,6 +237,7 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { jgitClient.setKeyCache(getKeyCache()); jgitClient.setCredentialsProvider(credentialsProvider); jgitClient.setProxyDatabase(proxies); + jgitClient.setKeyPasswordProviderFactory(keyPasswordProvider); String defaultAuths = getDefaultPreferredAuthentications(); if (defaultAuths != null) { jgitClient.setAttribute( @@ -386,7 +388,7 @@ public class SshdSessionFactory extends SshSessionFactory implements Closeable { } /** - * Obtains a {@link SshConfigStore}, or {@code null} if not SSH config is to + * Obtains a {@link SshConfigStore}, or {@code null} if no SSH config is to * be used. The default implementation returns {@code null} if * {@code configFile == null} and otherwise an OpenSSH-compatible store * reading host entries from the given file. |