diff options
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. |