aboutsummaryrefslogtreecommitdiffstats
path: root/org.eclipse.jgit.ssh.apache
diff options
context:
space:
mode:
Diffstat (limited to 'org.eclipse.jgit.ssh.apache')
-rw-r--r--org.eclipse.jgit.ssh.apache/.classpath12
-rw-r--r--org.eclipse.jgit.ssh.apache/.fbprefs125
-rw-r--r--org.eclipse.jgit.ssh.apache/.gitignore2
-rw-r--r--org.eclipse.jgit.ssh.apache/.project34
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.resources.prefs3
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.runtime.prefs3
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.core.prefs518
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.ui.prefs66
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.tasks.ui.prefs4
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.team.ui.prefs3
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.api.tools.prefs104
-rw-r--r--org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.core.prefs3
-rw-r--r--org.eclipse.jgit.ssh.apache/BUILD24
-rw-r--r--org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF104
-rw-r--r--org.eclipse.jgit.ssh.apache/META-INF/SOURCE-MANIFEST.MF8
-rw-r--r--org.eclipse.jgit.ssh.apache/OSGI-INF/l10n/plugin.properties2
-rw-r--r--org.eclipse.jgit.ssh.apache/README.md160
-rw-r--r--org.eclipse.jgit.ssh.apache/about.html96
-rw-r--r--org.eclipse.jgit.ssh.apache/build.properties7
-rw-r--r--org.eclipse.jgit.ssh.apache/manual_tests.txt45
-rw-r--r--org.eclipse.jgit.ssh.apache/pom.xml232
-rw-r--r--org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory1
-rw-r--r--org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory1
-rw-r--r--org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory1
-rw-r--r--org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties194
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java530
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java491
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java120
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java161
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java131
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java59
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java175
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java38
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java319
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java485
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationCanceledException.java30
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java238
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java246
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java198
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthFactory.java39
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java280
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java93
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java744
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitHostConfigEntry.java52
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthFactory.java35
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java579
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitServerKeyVerifier.java157
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java490
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java93
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java187
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java175
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java824
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java138
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/ServerKeyLookup.java36
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java218
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/ConnectorFactoryProvider.java61
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java82
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java475
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java56
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java88
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java128
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java114
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/Pkcs11Provider.java372
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/pkcs11/SecurityCallback.java246
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java177
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java90
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java372
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java287
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java608
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java59
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java66
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java49
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java94
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java34
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java33
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java63
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java67
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java298
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/JGitKeyCache.java55
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyCache.java41
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProvider.java86
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java69
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java103
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java33
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ServerKeyDatabase.java144
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java28
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java606
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java674
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java444
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/AbstractConnector.java116
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/Connector.java62
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/ConnectorFactory.java173
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/package-info.java6
-rw-r--r--org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/package-info.java6
94 files changed, 15678 insertions, 0 deletions
diff --git a/org.eclipse.jgit.ssh.apache/.classpath b/org.eclipse.jgit.ssh.apache/.classpath
new file mode 100644
index 0000000000..efeb803f8b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.classpath
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
+ <attributes>
+ <attribute name="module" value="true"/>
+ </attributes>
+ </classpathentry>
+ <classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/>
+ <classpathentry kind="src" path="src"/>
+ <classpathentry kind="src" path="resources"/>
+ <classpathentry kind="output" path="bin"/>
+</classpath>
diff --git a/org.eclipse.jgit.ssh.apache/.fbprefs b/org.eclipse.jgit.ssh.apache/.fbprefs
new file mode 100644
index 0000000000..81a0767ff6
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.fbprefs
@@ -0,0 +1,125 @@
+#FindBugs User Preferences
+#Mon May 04 16:24:13 PDT 2009
+detectorAppendingToAnObjectOutputStream=AppendingToAnObjectOutputStream|true
+detectorBadAppletConstructor=BadAppletConstructor|false
+detectorBadResultSetAccess=BadResultSetAccess|true
+detectorBadSyntaxForRegularExpression=BadSyntaxForRegularExpression|true
+detectorBadUseOfReturnValue=BadUseOfReturnValue|true
+detectorBadlyOverriddenAdapter=BadlyOverriddenAdapter|true
+detectorBooleanReturnNull=BooleanReturnNull|true
+detectorCallToUnsupportedMethod=CallToUnsupportedMethod|true
+detectorCheckImmutableAnnotation=CheckImmutableAnnotation|true
+detectorCheckTypeQualifiers=CheckTypeQualifiers|true
+detectorCloneIdiom=CloneIdiom|false
+detectorComparatorIdiom=ComparatorIdiom|true
+detectorConfusedInheritance=ConfusedInheritance|true
+detectorConfusionBetweenInheritedAndOuterMethod=ConfusionBetweenInheritedAndOuterMethod|true
+detectorCrossSiteScripting=CrossSiteScripting|true
+detectorDoInsideDoPrivileged=DoInsideDoPrivileged|true
+detectorDontCatchIllegalMonitorStateException=DontCatchIllegalMonitorStateException|true
+detectorDontUseEnum=DontUseEnum|true
+detectorDroppedException=DroppedException|true
+detectorDumbMethodInvocations=DumbMethodInvocations|true
+detectorDumbMethods=DumbMethods|true
+detectorDuplicateBranches=DuplicateBranches|true
+detectorEmptyZipFileEntry=EmptyZipFileEntry|true
+detectorEqualsOperandShouldHaveClassCompatibleWithThis=EqualsOperandShouldHaveClassCompatibleWithThis|true
+detectorFinalizerNullsFields=FinalizerNullsFields|true
+detectorFindBadCast2=FindBadCast2|true
+detectorFindBadForLoop=FindBadForLoop|true
+detectorFindCircularDependencies=FindCircularDependencies|false
+detectorFindDeadLocalStores=FindDeadLocalStores|true
+detectorFindDoubleCheck=FindDoubleCheck|true
+detectorFindEmptySynchronizedBlock=FindEmptySynchronizedBlock|true
+detectorFindFieldSelfAssignment=FindFieldSelfAssignment|true
+detectorFindFinalizeInvocations=FindFinalizeInvocations|true
+detectorFindFloatEquality=FindFloatEquality|true
+detectorFindHEmismatch=FindHEmismatch|true
+detectorFindInconsistentSync2=FindInconsistentSync2|true
+detectorFindJSR166LockMonitorenter=FindJSR166LockMonitorenter|true
+detectorFindLocalSelfAssignment2=FindLocalSelfAssignment2|true
+detectorFindMaskedFields=FindMaskedFields|true
+detectorFindMismatchedWaitOrNotify=FindMismatchedWaitOrNotify|true
+detectorFindNakedNotify=FindNakedNotify|true
+detectorFindNonSerializableStoreIntoSession=FindNonSerializableStoreIntoSession|true
+detectorFindNonSerializableValuePassedToWriteObject=FindNonSerializableValuePassedToWriteObject|true
+detectorFindNonShortCircuit=FindNonShortCircuit|true
+detectorFindNullDeref=FindNullDeref|true
+detectorFindNullDerefsInvolvingNonShortCircuitEvaluation=FindNullDerefsInvolvingNonShortCircuitEvaluation|true
+detectorFindOpenStream=FindOpenStream|true
+detectorFindPuzzlers=FindPuzzlers|true
+detectorFindRefComparison=FindRefComparison|true
+detectorFindReturnRef=FindReturnRef|true
+detectorFindRunInvocations=FindRunInvocations|true
+detectorFindSelfComparison=FindSelfComparison|true
+detectorFindSelfComparison2=FindSelfComparison2|true
+detectorFindSleepWithLockHeld=FindSleepWithLockHeld|true
+detectorFindSpinLoop=FindSpinLoop|true
+detectorFindSqlInjection=FindSqlInjection|true
+detectorFindTwoLockWait=FindTwoLockWait|true
+detectorFindUncalledPrivateMethods=FindUncalledPrivateMethods|true
+detectorFindUnconditionalWait=FindUnconditionalWait|true
+detectorFindUninitializedGet=FindUninitializedGet|true
+detectorFindUnrelatedTypesInGenericContainer=FindUnrelatedTypesInGenericContainer|true
+detectorFindUnreleasedLock=FindUnreleasedLock|true
+detectorFindUnsatisfiedObligation=FindUnsatisfiedObligation|true
+detectorFindUnsyncGet=FindUnsyncGet|true
+detectorFindUselessControlFlow=FindUselessControlFlow|true
+detectorFormatStringChecker=FormatStringChecker|true
+detectorHugeSharedStringConstants=HugeSharedStringConstants|true
+detectorIDivResultCastToDouble=IDivResultCastToDouble|true
+detectorIncompatMask=IncompatMask|true
+detectorInconsistentAnnotations=InconsistentAnnotations|true
+detectorInefficientMemberAccess=InefficientMemberAccess|false
+detectorInefficientToArray=InefficientToArray|true
+detectorInfiniteLoop=InfiniteLoop|true
+detectorInfiniteRecursiveLoop=InfiniteRecursiveLoop|true
+detectorInfiniteRecursiveLoop2=InfiniteRecursiveLoop2|false
+detectorInheritanceUnsafeGetResource=InheritanceUnsafeGetResource|true
+detectorInitializationChain=InitializationChain|true
+detectorInstantiateStaticClass=InstantiateStaticClass|true
+detectorInvalidJUnitTest=InvalidJUnitTest|true
+detectorIteratorIdioms=IteratorIdioms|true
+detectorLazyInit=LazyInit|true
+detectorLoadOfKnownNullValue=LoadOfKnownNullValue|true
+detectorMethodReturnCheck=MethodReturnCheck|true
+detectorMultithreadedInstanceAccess=MultithreadedInstanceAccess|true
+detectorMutableLock=MutableLock|true
+detectorMutableStaticFields=MutableStaticFields|true
+detectorNaming=Naming|true
+detectorNumberConstructor=NumberConstructor|true
+detectorOverridingEqualsNotSymmetrical=OverridingEqualsNotSymmetrical|true
+detectorPreferZeroLengthArrays=PreferZeroLengthArrays|true
+detectorPublicSemaphores=PublicSemaphores|false
+detectorQuestionableBooleanAssignment=QuestionableBooleanAssignment|true
+detectorReadReturnShouldBeChecked=ReadReturnShouldBeChecked|true
+detectorRedundantInterfaces=RedundantInterfaces|true
+detectorRepeatedConditionals=RepeatedConditionals|true
+detectorRuntimeExceptionCapture=RuntimeExceptionCapture|true
+detectorSerializableIdiom=SerializableIdiom|true
+detectorStartInConstructor=StartInConstructor|true
+detectorStaticCalendarDetector=StaticCalendarDetector|true
+detectorStringConcatenation=StringConcatenation|true
+detectorSuperfluousInstanceOf=SuperfluousInstanceOf|true
+detectorSuspiciousThreadInterrupted=SuspiciousThreadInterrupted|true
+detectorSwitchFallthrough=SwitchFallthrough|true
+detectorSynchronizeAndNullCheckField=SynchronizeAndNullCheckField|true
+detectorSynchronizeOnClassLiteralNotGetClass=SynchronizeOnClassLiteralNotGetClass|true
+detectorSynchronizingOnContentsOfFieldToProtectField=SynchronizingOnContentsOfFieldToProtectField|true
+detectorURLProblems=URLProblems|true
+detectorUncallableMethodOfAnonymousClass=UncallableMethodOfAnonymousClass|true
+detectorUnnecessaryMath=UnnecessaryMath|true
+detectorUnreadFields=UnreadFields|true
+detectorUseObjectEquals=UseObjectEquals|false
+detectorUselessSubclassMethod=UselessSubclassMethod|false
+detectorVarArgsProblems=VarArgsProblems|true
+detectorVolatileUsage=VolatileUsage|true
+detectorWaitInLoop=WaitInLoop|true
+detectorWrongMapIterator=WrongMapIterator|true
+detectorXMLFactoryBypass=XMLFactoryBypass|true
+detector_threshold=2
+effort=default
+excludefilter0=findBugs/FindBugsExcludeFilter.xml
+filter_settings=Medium|BAD_PRACTICE,CORRECTNESS,MT_CORRECTNESS,PERFORMANCE,STYLE|false
+filter_settings_neg=MALICIOUS_CODE,NOISE,I18N,SECURITY,EXPERIMENTAL|
+run_at_full_build=true
diff --git a/org.eclipse.jgit.ssh.apache/.gitignore b/org.eclipse.jgit.ssh.apache/.gitignore
new file mode 100644
index 0000000000..934e0e06ff
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.gitignore
@@ -0,0 +1,2 @@
+/bin
+/target
diff --git a/org.eclipse.jgit.ssh.apache/.project b/org.eclipse.jgit.ssh.apache/.project
new file mode 100644
index 0000000000..a7bbd6bafd
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.project
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+ <name>org.eclipse.jgit.ssh.apache</name>
+ <comment></comment>
+ <projects>
+ </projects>
+ <buildSpec>
+ <buildCommand>
+ <name>org.eclipse.jdt.core.javabuilder</name>
+ <arguments>
+ </arguments>
+ </buildCommand>
+ <buildCommand>
+ <name>org.eclipse.pde.ManifestBuilder</name>
+ <arguments>
+ </arguments>
+ </buildCommand>
+ <buildCommand>
+ <name>org.eclipse.pde.SchemaBuilder</name>
+ <arguments>
+ </arguments>
+ </buildCommand>
+ <buildCommand>
+ <name>org.eclipse.pde.api.tools.apiAnalysisBuilder</name>
+ <arguments>
+ </arguments>
+ </buildCommand>
+ </buildSpec>
+ <natures>
+ <nature>org.eclipse.pde.PluginNature</nature>
+ <nature>org.eclipse.jdt.core.javanature</nature>
+ <nature>org.eclipse.pde.api.tools.apiAnalysisNature</nature>
+ </natures>
+</projectDescription>
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.resources.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.resources.prefs
new file mode 100644
index 0000000000..66ac15c47c
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.resources.prefs
@@ -0,0 +1,3 @@
+#Mon Aug 11 16:46:12 PDT 2008
+eclipse.preferences.version=1
+encoding/<project>=UTF-8
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.runtime.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.runtime.prefs
new file mode 100644
index 0000000000..006e07ede5
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.core.runtime.prefs
@@ -0,0 +1,3 @@
+#Mon Mar 24 18:55:50 EDT 2008
+eclipse.preferences.version=1
+line.separator=\n
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.core.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000000..270fc6417e
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,518 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=enabled
+org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
+org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jgit.annotations.NonNull
+org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jgit.annotations.NonNullByDefault
+org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jgit.annotations.Nullable
+org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled
+org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
+org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
+org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
+org.eclipse.jdt.core.compiler.compliance=17
+org.eclipse.jdt.core.compiler.debug.lineNumber=generate
+org.eclipse.jdt.core.compiler.debug.localVariable=generate
+org.eclipse.jdt.core.compiler.debug.sourceFile=generate
+org.eclipse.jdt.core.compiler.doc.comment.support=enabled
+org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=warning
+org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
+org.eclipse.jdt.core.compiler.problem.autoboxing=warning
+org.eclipse.jdt.core.compiler.problem.comparingIdentical=error
+org.eclipse.jdt.core.compiler.problem.deadCode=error
+org.eclipse.jdt.core.compiler.problem.deprecation=warning
+org.eclipse.jdt.core.compiler.problem.deprecationInDeprecatedCode=disabled
+org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
+org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
+org.eclipse.jdt.core.compiler.problem.emptyStatement=warning
+org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
+org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
+org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=warning
+org.eclipse.jdt.core.compiler.problem.fallthroughCase=warning
+org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled
+org.eclipse.jdt.core.compiler.problem.fieldHiding=warning
+org.eclipse.jdt.core.compiler.problem.finalParameterBound=warning
+org.eclipse.jdt.core.compiler.problem.finallyBlockNotCompletingNormally=error
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=error
+org.eclipse.jdt.core.compiler.problem.hiddenCatchBlock=error
+org.eclipse.jdt.core.compiler.problem.includeNullInfoFromAsserts=enabled
+org.eclipse.jdt.core.compiler.problem.incompatibleNonInheritedInterfaceMethod=warning
+org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning
+org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=error
+org.eclipse.jdt.core.compiler.problem.invalidJavadoc=error
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTags=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsDeprecatedRef=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsNotVisibleRef=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsVisibility=private
+org.eclipse.jdt.core.compiler.problem.localVariableHiding=warning
+org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=error
+org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore
+org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore
+org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled
+org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=error
+org.eclipse.jdt.core.compiler.problem.missingJavadocComments=error
+org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsVisibility=protected
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagDescription=all_standard_tags
+org.eclipse.jdt.core.compiler.problem.missingJavadocTags=error
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsMethodTypeParameters=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsVisibility=private
+org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=warning
+org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled
+org.eclipse.jdt.core.compiler.problem.missingSerialVersion=warning
+org.eclipse.jdt.core.compiler.problem.missingSynchronizedOnInheritedMethod=ignore
+org.eclipse.jdt.core.compiler.problem.noEffectAssignment=error
+org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=error
+org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=warning
+org.eclipse.jdt.core.compiler.problem.nonnullParameterAnnotationDropped=warning
+org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error
+org.eclipse.jdt.core.compiler.problem.nullReference=error
+org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error
+org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=ignore
+org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning
+org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore
+org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=error
+org.eclipse.jdt.core.compiler.problem.potentialNullReference=error
+org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore
+org.eclipse.jdt.core.compiler.problem.rawTypeReference=ignore
+org.eclipse.jdt.core.compiler.problem.redundantNullAnnotation=warning
+org.eclipse.jdt.core.compiler.problem.redundantNullCheck=warning
+org.eclipse.jdt.core.compiler.problem.redundantSpecificationOfTypeArguments=warning
+org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=error
+org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
+org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
+org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
+org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
+org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=error
+org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
+org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
+org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=disabled
+org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
+org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
+org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
+org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
+org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
+org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=warning
+org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.unnecessaryElse=warning
+org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=error
+org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=warning
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionExemptExceptionAndThrowable=enabled
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionIncludeDocCommentReference=enabled
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionWhenOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.unusedExceptionParameter=ignore
+org.eclipse.jdt.core.compiler.problem.unusedImport=error
+org.eclipse.jdt.core.compiler.problem.unusedLabel=error
+org.eclipse.jdt.core.compiler.problem.unusedLocal=error
+org.eclipse.jdt.core.compiler.problem.unusedObjectAllocation=warning
+org.eclipse.jdt.core.compiler.problem.unusedParameter=warning
+org.eclipse.jdt.core.compiler.problem.unusedParameterIncludeDocCommentReference=enabled
+org.eclipse.jdt.core.compiler.problem.unusedParameterWhenImplementingAbstract=disabled
+org.eclipse.jdt.core.compiler.problem.unusedParameterWhenOverridingConcrete=disabled
+org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=error
+org.eclipse.jdt.core.compiler.problem.unusedTypeParameter=ignore
+org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=error
+org.eclipse.jdt.core.compiler.release=enabled
+org.eclipse.jdt.core.compiler.source=17
+org.eclipse.jdt.core.formatter.align_assignment_statements_on_columns=false
+org.eclipse.jdt.core.formatter.align_fields_grouping_blank_lines=2147483647
+org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
+org.eclipse.jdt.core.formatter.align_variable_declarations_on_columns=false
+org.eclipse.jdt.core.formatter.align_with_spaces=false
+org.eclipse.jdt.core.formatter.alignment_for_additive_operator=16
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_enum_constant=0
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_field=49
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_local_variable=49
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_method=49
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_package=49
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_parameter=0
+org.eclipse.jdt.core.formatter.alignment_for_annotations_on_type=49
+org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
+org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation=0
+org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
+org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call=16
+org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation=16
+org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression=16
+org.eclipse.jdt.core.formatter.alignment_for_assertion_message=0
+org.eclipse.jdt.core.formatter.alignment_for_assignment=0
+org.eclipse.jdt.core.formatter.alignment_for_binary_expression=16
+org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator=16
+org.eclipse.jdt.core.formatter.alignment_for_compact_if=16
+org.eclipse.jdt.core.formatter.alignment_for_compact_loops=16
+org.eclipse.jdt.core.formatter.alignment_for_conditional_expression=80
+org.eclipse.jdt.core.formatter.alignment_for_conditional_expression_chain=0
+org.eclipse.jdt.core.formatter.alignment_for_enum_constants=0
+org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer=16
+org.eclipse.jdt.core.formatter.alignment_for_expressions_in_for_loop_header=0
+org.eclipse.jdt.core.formatter.alignment_for_logical_operator=16
+org.eclipse.jdt.core.formatter.alignment_for_method_declaration=0
+org.eclipse.jdt.core.formatter.alignment_for_module_statements=16
+org.eclipse.jdt.core.formatter.alignment_for_multiple_fields=16
+org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator=16
+org.eclipse.jdt.core.formatter.alignment_for_parameterized_type_references=0
+org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_record_components=16
+org.eclipse.jdt.core.formatter.alignment_for_relational_operator=0
+org.eclipse.jdt.core.formatter.alignment_for_resources_in_try=80
+org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation=16
+org.eclipse.jdt.core.formatter.alignment_for_shift_operator=0
+org.eclipse.jdt.core.formatter.alignment_for_string_concatenation=16
+org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_record_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration=16
+org.eclipse.jdt.core.formatter.alignment_for_type_annotations=0
+org.eclipse.jdt.core.formatter.alignment_for_type_arguments=0
+org.eclipse.jdt.core.formatter.alignment_for_type_parameters=0
+org.eclipse.jdt.core.formatter.alignment_for_union_type_in_multicatch=16
+org.eclipse.jdt.core.formatter.blank_lines_after_imports=1
+org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration=0
+org.eclipse.jdt.core.formatter.blank_lines_after_package=1
+org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method=1
+org.eclipse.jdt.core.formatter.blank_lines_before_field=1
+org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration=0
+org.eclipse.jdt.core.formatter.blank_lines_before_imports=1
+org.eclipse.jdt.core.formatter.blank_lines_before_member_type=1
+org.eclipse.jdt.core.formatter.blank_lines_before_method=1
+org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk=1
+org.eclipse.jdt.core.formatter.blank_lines_before_package=0
+org.eclipse.jdt.core.formatter.blank_lines_between_import_groups=1
+org.eclipse.jdt.core.formatter.blank_lines_between_statement_group_in_switch=0
+org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations=1
+org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_array_initializer=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_block=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_block_in_case=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_enum_constant=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_lambda_body=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_method_declaration=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_record_constructor=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_record_declaration=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_switch=end_of_line
+org.eclipse.jdt.core.formatter.brace_position_for_type_declaration=end_of_line
+org.eclipse.jdt.core.formatter.comment.align_tags_descriptions_grouped=false
+org.eclipse.jdt.core.formatter.comment.align_tags_names_descriptions=false
+org.eclipse.jdt.core.formatter.comment.clear_blank_lines=false
+org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment=false
+org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment=false
+org.eclipse.jdt.core.formatter.comment.count_line_length_from_starting_position=false
+org.eclipse.jdt.core.formatter.comment.format_block_comments=true
+org.eclipse.jdt.core.formatter.comment.format_comments=true
+org.eclipse.jdt.core.formatter.comment.format_header=false
+org.eclipse.jdt.core.formatter.comment.format_html=true
+org.eclipse.jdt.core.formatter.comment.format_javadoc_comments=true
+org.eclipse.jdt.core.formatter.comment.format_line_comments=true
+org.eclipse.jdt.core.formatter.comment.format_source_code=true
+org.eclipse.jdt.core.formatter.comment.indent_parameter_description=true
+org.eclipse.jdt.core.formatter.comment.indent_root_tags=true
+org.eclipse.jdt.core.formatter.comment.indent_tag_description=false
+org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags=insert
+org.eclipse.jdt.core.formatter.comment.insert_new_line_between_different_tags=do not insert
+org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter=insert
+org.eclipse.jdt.core.formatter.comment.line_length=80
+org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries=true
+org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries=true
+org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments=false
+org.eclipse.jdt.core.formatter.compact_else_if=true
+org.eclipse.jdt.core.formatter.continuation_indentation=2
+org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer=2
+org.eclipse.jdt.core.formatter.disabling_tag=@formatter\:off
+org.eclipse.jdt.core.formatter.enabling_tag=@formatter\:on
+org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line=false
+org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column=false
+org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header=true
+org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header=true
+org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header=true
+org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_record_header=true
+org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header=true
+org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases=true
+org.eclipse.jdt.core.formatter.indent_empty_lines=false
+org.eclipse.jdt.core.formatter.indent_statements_compare_to_block=true
+org.eclipse.jdt.core.formatter.indent_statements_compare_to_body=true
+org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases=true
+org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch=false
+org.eclipse.jdt.core.formatter.indentation.size=4
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type=insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_label=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement=do not insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration=insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration=insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block=insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant=insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration=insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body=insert
+org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_after_additive_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter=insert
+org.eclipse.jdt.core.formatter.insert_space_after_arrow_in_switch_case=insert
+org.eclipse.jdt.core.formatter.insert_space_after_arrow_in_switch_default=insert
+org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_binary_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_bitwise_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments=insert
+org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters=insert
+org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block=insert
+org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast=insert
+org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert=insert
+org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case=insert
+org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional=insert
+org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for=insert
+org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_record_components=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_switch_case_expressions=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments=insert
+org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters=insert
+org.eclipse.jdt.core.formatter.insert_space_after_ellipsis=insert
+org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow=insert
+org.eclipse.jdt.core.formatter.insert_space_after_logical_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_multiplicative_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_not_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer=insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_record_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_try=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional=insert
+org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard=do not insert
+org.eclipse.jdt.core.formatter.insert_space_after_relational_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for=insert
+org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_try_resources=insert
+org.eclipse.jdt.core.formatter.insert_space_after_shift_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_after_string_concatenation=insert
+org.eclipse.jdt.core.formatter.insert_space_after_unary_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_additive_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter=insert
+org.eclipse.jdt.core.formatter.insert_space_before_arrow_in_switch_case=insert
+org.eclipse.jdt.core.formatter.insert_space_before_arrow_in_switch_default=insert
+org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_binary_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_bitwise_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer=insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_record_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_try=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert=insert
+org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional=insert
+org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for=insert
+org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_record_components=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_switch_case_expressions=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_ellipsis=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow=insert
+org.eclipse.jdt.core.formatter.insert_space_before_logical_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_multiplicative_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_record_constructor=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_record_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_record_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_try=insert
+org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while=insert
+org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return=insert
+org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw=insert
+org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional=insert
+org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_relational_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_semicolon=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_try_resources=do not insert
+org.eclipse.jdt.core.formatter.insert_space_before_shift_operator=insert
+org.eclipse.jdt.core.formatter.insert_space_before_string_concatenation=insert
+org.eclipse.jdt.core.formatter.insert_space_before_unary_operator=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration=do not insert
+org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation=do not insert
+org.eclipse.jdt.core.formatter.join_lines_in_comments=true
+org.eclipse.jdt.core.formatter.join_wrapped_lines=true
+org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_code_block_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=false
+org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false
+org.eclipse.jdt.core.formatter.keep_enum_constant_declaration_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=false
+org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_method_body_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_record_constructor_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_record_declaration_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line=false
+org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line=false
+org.eclipse.jdt.core.formatter.keep_simple_getter_setter_on_one_line=false
+org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line=false
+org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=false
+org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line=one_line_never
+org.eclipse.jdt.core.formatter.lineSplit=80
+org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=false
+org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=false
+org.eclipse.jdt.core.formatter.number_of_blank_lines_after_code_block=0
+org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_code_block=0
+org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body=0
+org.eclipse.jdt.core.formatter.number_of_blank_lines_at_end_of_code_block=0
+org.eclipse.jdt.core.formatter.number_of_blank_lines_at_end_of_method_body=0
+org.eclipse.jdt.core.formatter.number_of_blank_lines_before_code_block=0
+org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve=1
+org.eclipse.jdt.core.formatter.parentheses_positions_in_annotation=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_catch_clause=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_enum_constant_declaration=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_for_statment=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_if_while_statement=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_lambda_declaration=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_method_delcaration=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_method_invocation=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_record_declaration=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_switch_statement=common_lines
+org.eclipse.jdt.core.formatter.parentheses_positions_in_try_clause=common_lines
+org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line=true
+org.eclipse.jdt.core.formatter.tabulation.char=tab
+org.eclipse.jdt.core.formatter.tabulation.size=4
+org.eclipse.jdt.core.formatter.text_block_indentation=0
+org.eclipse.jdt.core.formatter.use_on_off_tags=true
+org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations=false
+org.eclipse.jdt.core.formatter.wrap_before_additive_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_assertion_message_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_assignment_operator=false
+org.eclipse.jdt.core.formatter.wrap_before_binary_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_bitwise_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_conditional_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_logical_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_multiplicative_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_or_operator_multicatch=true
+org.eclipse.jdt.core.formatter.wrap_before_relational_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_shift_operator=true
+org.eclipse.jdt.core.formatter.wrap_before_string_concatenation=true
+org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested=true
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.ui.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.ui.prefs
new file mode 100644
index 0000000000..5cfb8b6ac6
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.jdt.ui.prefs
@@ -0,0 +1,66 @@
+eclipse.preferences.version=1
+editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true
+formatter_profile=_JGit Format
+formatter_settings_version=21
+org.eclipse.jdt.ui.ignorelowercasenames=true
+org.eclipse.jdt.ui.importorder=java;javax;org;com;
+org.eclipse.jdt.ui.ondemandthreshold=99
+org.eclipse.jdt.ui.staticondemandthreshold=99
+org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8"?><templates/>
+sp_cleanup.add_default_serial_version_id=true
+sp_cleanup.add_generated_serial_version_id=false
+sp_cleanup.add_missing_annotations=true
+sp_cleanup.add_missing_deprecated_annotations=true
+sp_cleanup.add_missing_methods=false
+sp_cleanup.add_missing_nls_tags=false
+sp_cleanup.add_missing_override_annotations=true
+sp_cleanup.add_missing_override_annotations_interface_methods=true
+sp_cleanup.add_serial_version_id=false
+sp_cleanup.always_use_blocks=true
+sp_cleanup.always_use_parentheses_in_expressions=false
+sp_cleanup.always_use_this_for_non_static_field_access=false
+sp_cleanup.always_use_this_for_non_static_method_access=false
+sp_cleanup.convert_functional_interfaces=false
+sp_cleanup.convert_to_enhanced_for_loop=false
+sp_cleanup.correct_indentation=false
+sp_cleanup.format_source_code=true
+sp_cleanup.format_source_code_changes_only=true
+sp_cleanup.insert_inferred_type_arguments=false
+sp_cleanup.make_local_variable_final=false
+sp_cleanup.make_parameters_final=false
+sp_cleanup.make_private_fields_final=true
+sp_cleanup.make_type_abstract_if_missing_method=false
+sp_cleanup.make_variable_declarations_final=false
+sp_cleanup.never_use_blocks=false
+sp_cleanup.never_use_parentheses_in_expressions=true
+sp_cleanup.on_save_use_additional_actions=true
+sp_cleanup.organize_imports=false
+sp_cleanup.qualify_static_field_accesses_with_declaring_class=false
+sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true
+sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true
+sp_cleanup.qualify_static_member_accesses_with_declaring_class=false
+sp_cleanup.qualify_static_method_accesses_with_declaring_class=false
+sp_cleanup.remove_private_constructors=true
+sp_cleanup.remove_redundant_type_arguments=true
+sp_cleanup.remove_trailing_whitespaces=true
+sp_cleanup.remove_trailing_whitespaces_all=true
+sp_cleanup.remove_trailing_whitespaces_ignore_empty=false
+sp_cleanup.remove_unnecessary_casts=true
+sp_cleanup.remove_unnecessary_nls_tags=true
+sp_cleanup.remove_unused_imports=false
+sp_cleanup.remove_unused_local_variables=false
+sp_cleanup.remove_unused_private_fields=true
+sp_cleanup.remove_unused_private_members=false
+sp_cleanup.remove_unused_private_methods=true
+sp_cleanup.remove_unused_private_types=true
+sp_cleanup.sort_members=false
+sp_cleanup.sort_members_all=false
+sp_cleanup.use_anonymous_class_creation=false
+sp_cleanup.use_blocks=false
+sp_cleanup.use_blocks_only_for_return_and_throw=false
+sp_cleanup.use_lambda=false
+sp_cleanup.use_parentheses_in_expressions=false
+sp_cleanup.use_this_for_non_static_field_access=false
+sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true
+sp_cleanup.use_this_for_non_static_method_access=false
+sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.tasks.ui.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.tasks.ui.prefs
new file mode 100644
index 0000000000..823c0f56ae
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.tasks.ui.prefs
@@ -0,0 +1,4 @@
+#Tue Jul 19 20:11:28 CEST 2011
+eclipse.preferences.version=1
+project.repository.kind=bugzilla
+project.repository.url=https\://bugs.eclipse.org/bugs
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.team.ui.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.team.ui.prefs
new file mode 100644
index 0000000000..0cba949fb7
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.mylyn.team.ui.prefs
@@ -0,0 +1,3 @@
+#Tue Jul 19 20:11:28 CEST 2011
+commit.comment.template=${task.description} \n\nBug\: ${task.key}
+eclipse.preferences.version=1
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.api.tools.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.api.tools.prefs
new file mode 100644
index 0000000000..c0030ded71
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.api.tools.prefs
@@ -0,0 +1,104 @@
+ANNOTATION_ELEMENT_TYPE_ADDED_FIELD=Error
+ANNOTATION_ELEMENT_TYPE_ADDED_METHOD_WITHOUT_DEFAULT_VALUE=Error
+ANNOTATION_ELEMENT_TYPE_CHANGED_TYPE_CONVERSION=Error
+ANNOTATION_ELEMENT_TYPE_REMOVED_FIELD=Error
+ANNOTATION_ELEMENT_TYPE_REMOVED_METHOD=Error
+ANNOTATION_ELEMENT_TYPE_REMOVED_TYPE_MEMBER=Error
+API_COMPONENT_ELEMENT_TYPE_REMOVED_API_TYPE=Error
+API_COMPONENT_ELEMENT_TYPE_REMOVED_REEXPORTED_API_TYPE=Error
+API_COMPONENT_ELEMENT_TYPE_REMOVED_REEXPORTED_TYPE=Error
+API_COMPONENT_ELEMENT_TYPE_REMOVED_TYPE=Error
+API_USE_SCAN_FIELD_SEVERITY=Error
+API_USE_SCAN_METHOD_SEVERITY=Error
+API_USE_SCAN_TYPE_SEVERITY=Error
+CLASS_ELEMENT_TYPE_ADDED_FIELD=Error
+CLASS_ELEMENT_TYPE_ADDED_METHOD=Error
+CLASS_ELEMENT_TYPE_ADDED_RESTRICTIONS=Error
+CLASS_ELEMENT_TYPE_ADDED_TYPE_PARAMETER=Error
+CLASS_ELEMENT_TYPE_CHANGED_CONTRACTED_SUPERINTERFACES_SET=Error
+CLASS_ELEMENT_TYPE_CHANGED_DECREASE_ACCESS=Error
+CLASS_ELEMENT_TYPE_CHANGED_NON_ABSTRACT_TO_ABSTRACT=Error
+CLASS_ELEMENT_TYPE_CHANGED_NON_FINAL_TO_FINAL=Error
+CLASS_ELEMENT_TYPE_CHANGED_TYPE_CONVERSION=Error
+CLASS_ELEMENT_TYPE_REMOVED_CONSTRUCTOR=Error
+CLASS_ELEMENT_TYPE_REMOVED_FIELD=Error
+CLASS_ELEMENT_TYPE_REMOVED_METHOD=Error
+CLASS_ELEMENT_TYPE_REMOVED_SUPERCLASS=Error
+CLASS_ELEMENT_TYPE_REMOVED_TYPE_MEMBER=Error
+CLASS_ELEMENT_TYPE_REMOVED_TYPE_PARAMETER=Error
+CONSTRUCTOR_ELEMENT_TYPE_ADDED_TYPE_PARAMETER=Error
+CONSTRUCTOR_ELEMENT_TYPE_CHANGED_DECREASE_ACCESS=Error
+CONSTRUCTOR_ELEMENT_TYPE_CHANGED_VARARGS_TO_ARRAY=Error
+CONSTRUCTOR_ELEMENT_TYPE_REMOVED_TYPE_PARAMETER=Error
+ENUM_ELEMENT_TYPE_CHANGED_CONTRACTED_SUPERINTERFACES_SET=Error
+ENUM_ELEMENT_TYPE_CHANGED_TYPE_CONVERSION=Error
+ENUM_ELEMENT_TYPE_REMOVED_ENUM_CONSTANT=Error
+ENUM_ELEMENT_TYPE_REMOVED_FIELD=Error
+ENUM_ELEMENT_TYPE_REMOVED_METHOD=Error
+ENUM_ELEMENT_TYPE_REMOVED_TYPE_MEMBER=Error
+FIELD_ELEMENT_TYPE_ADDED_VALUE=Error
+FIELD_ELEMENT_TYPE_CHANGED_DECREASE_ACCESS=Error
+FIELD_ELEMENT_TYPE_CHANGED_FINAL_TO_NON_FINAL_STATIC_CONSTANT=Error
+FIELD_ELEMENT_TYPE_CHANGED_NON_FINAL_TO_FINAL=Error
+FIELD_ELEMENT_TYPE_CHANGED_NON_STATIC_TO_STATIC=Error
+FIELD_ELEMENT_TYPE_CHANGED_STATIC_TO_NON_STATIC=Error
+FIELD_ELEMENT_TYPE_CHANGED_TYPE=Error
+FIELD_ELEMENT_TYPE_CHANGED_VALUE=Error
+FIELD_ELEMENT_TYPE_REMOVED_TYPE_ARGUMENT=Error
+FIELD_ELEMENT_TYPE_REMOVED_VALUE=Error
+ILLEGAL_EXTEND=Warning
+ILLEGAL_IMPLEMENT=Warning
+ILLEGAL_INSTANTIATE=Warning
+ILLEGAL_OVERRIDE=Warning
+ILLEGAL_REFERENCE=Warning
+INTERFACE_ELEMENT_TYPE_ADDED_DEFAULT_METHOD=Error
+INTERFACE_ELEMENT_TYPE_ADDED_FIELD=Error
+INTERFACE_ELEMENT_TYPE_ADDED_METHOD=Error
+INTERFACE_ELEMENT_TYPE_ADDED_RESTRICTIONS=Error
+INTERFACE_ELEMENT_TYPE_ADDED_SUPER_INTERFACE_WITH_METHODS=Error
+INTERFACE_ELEMENT_TYPE_ADDED_TYPE_PARAMETER=Error
+INTERFACE_ELEMENT_TYPE_CHANGED_CONTRACTED_SUPERINTERFACES_SET=Error
+INTERFACE_ELEMENT_TYPE_CHANGED_TYPE_CONVERSION=Error
+INTERFACE_ELEMENT_TYPE_REMOVED_FIELD=Error
+INTERFACE_ELEMENT_TYPE_REMOVED_METHOD=Error
+INTERFACE_ELEMENT_TYPE_REMOVED_TYPE_MEMBER=Error
+INTERFACE_ELEMENT_TYPE_REMOVED_TYPE_PARAMETER=Error
+INVALID_ANNOTATION=Ignore
+INVALID_JAVADOC_TAG=Ignore
+INVALID_REFERENCE_IN_SYSTEM_LIBRARIES=Error
+LEAK_EXTEND=Warning
+LEAK_FIELD_DECL=Warning
+LEAK_IMPLEMENT=Warning
+LEAK_METHOD_PARAM=Warning
+LEAK_METHOD_RETURN_TYPE=Warning
+METHOD_ELEMENT_TYPE_ADDED_RESTRICTIONS=Error
+METHOD_ELEMENT_TYPE_ADDED_TYPE_PARAMETER=Error
+METHOD_ELEMENT_TYPE_CHANGED_DECREASE_ACCESS=Error
+METHOD_ELEMENT_TYPE_CHANGED_NON_ABSTRACT_TO_ABSTRACT=Error
+METHOD_ELEMENT_TYPE_CHANGED_NON_FINAL_TO_FINAL=Error
+METHOD_ELEMENT_TYPE_CHANGED_NON_STATIC_TO_STATIC=Error
+METHOD_ELEMENT_TYPE_CHANGED_STATIC_TO_NON_STATIC=Error
+METHOD_ELEMENT_TYPE_CHANGED_VARARGS_TO_ARRAY=Error
+METHOD_ELEMENT_TYPE_REMOVED_ANNOTATION_DEFAULT_VALUE=Error
+METHOD_ELEMENT_TYPE_REMOVED_TYPE_PARAMETER=Error
+MISSING_EE_DESCRIPTIONS=Warning
+TYPE_PARAMETER_ELEMENT_TYPE_ADDED_CLASS_BOUND=Error
+TYPE_PARAMETER_ELEMENT_TYPE_ADDED_INTERFACE_BOUND=Error
+TYPE_PARAMETER_ELEMENT_TYPE_CHANGED_CLASS_BOUND=Error
+TYPE_PARAMETER_ELEMENT_TYPE_CHANGED_INTERFACE_BOUND=Error
+TYPE_PARAMETER_ELEMENT_TYPE_REMOVED_CLASS_BOUND=Error
+TYPE_PARAMETER_ELEMENT_TYPE_REMOVED_INTERFACE_BOUND=Error
+UNUSED_PROBLEM_FILTERS=Warning
+automatically_removed_unused_problem_filters=false
+changed_execution_env=Error
+eclipse.preferences.version=1
+incompatible_api_component_version=Error
+incompatible_api_component_version_include_major_without_breaking_change=Disabled
+incompatible_api_component_version_include_minor_without_api_change=Disabled
+incompatible_api_component_version_report_major_without_breaking_change=Warning
+incompatible_api_component_version_report_minor_without_api_change=Ignore
+invalid_since_tag_version=Error
+malformed_since_tag=Error
+missing_since_tag=Error
+report_api_breakage_when_major_version_incremented=Disabled
+report_resolution_errors_api_component=Warning
diff --git a/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.core.prefs b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.core.prefs
new file mode 100644
index 0000000000..82793f2d27
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/.settings/org.eclipse.pde.core.prefs
@@ -0,0 +1,3 @@
+#Thu Jan 14 14:34:32 CST 2010
+eclipse.preferences.version=1
+resolve.requirebundle=false
diff --git a/org.eclipse.jgit.ssh.apache/BUILD b/org.eclipse.jgit.ssh.apache/BUILD
new file mode 100644
index 0000000000..c32635f7c5
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/BUILD
@@ -0,0 +1,24 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_visibility = ["//visibility:public"])
+
+SRCS = glob(["src/**/*.java"])
+
+RESOURCES = glob(["resources/**"])
+
+java_library(
+ name = "ssh-apache",
+ srcs = SRCS,
+ resource_strip_prefix = "org.eclipse.jgit.ssh.apache/resources",
+ resources = RESOURCES,
+ deps = [
+ "//lib:bcpg",
+ "//lib:bcpkix",
+ "//lib:bcprov",
+ "//lib:bcutil",
+ "//lib:slf4j-api",
+ "//lib:sshd-osgi",
+ "//lib:sshd-sftp",
+ "//org.eclipse.jgit:jgit",
+ ],
+)
diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
new file mode 100644
index 0000000000..e9f18d9f1a
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
@@ -0,0 +1,104 @@
+Manifest-Version: 1.0
+Bundle-ManifestVersion: 2
+Bundle-Name: %Bundle-Name
+Automatic-Module-Name: org.eclipse.jgit.ssh.apache
+Bundle-SymbolicName: org.eclipse.jgit.ssh.apache
+Bundle-Vendor: %Bundle-Vendor
+Bundle-Localization: OSGI-INF/l10n/plugin
+Bundle-ActivationPolicy: lazy
+Bundle-Version: 7.4.0.qualifier
+Bundle-RequiredExecutionEnvironment: JavaSE-17
+Bundle-SCM: url=https://github.com/eclipse-jgit/jgit, connection=scm:git:https://eclipse.gerrithub.io/eclipse-jgit/jgit.git, developerConnection=scm:git:https://eclipse.gerrithub.io/a/eclipse-jgit/jgit.git
+Export-Package: org.eclipse.jgit.internal.signing.ssh;version="7.4.0";x-friends:="org.eclipse.jgit.ssh.apache.test",
+ org.eclipse.jgit.internal.transport.sshd;version="7.4.0";x-friends:="org.eclipse.jgit.ssh.apache.test";
+ uses:="org.apache.sshd.client,
+ org.apache.sshd.client.auth,
+ org.apache.sshd.client.auth.keyboard,
+ org.apache.sshd.client.auth.pubkey,
+ org.apache.sshd.client.config.hosts,
+ org.apache.sshd.client.future,
+ org.apache.sshd.client.keyverifier,
+ org.apache.sshd.client.session,
+ org.apache.sshd.common.config.keys,
+ org.apache.sshd.common.io,
+ org.apache.sshd.common.keyprovider,
+ org.apache.sshd.common.signature,
+ org.apache.sshd.common.util.buffer,
+ org.eclipse.jgit.transport",
+ org.eclipse.jgit.internal.transport.sshd.agent;version="7.4.0";x-internal:=true,
+ org.eclipse.jgit.internal.transport.sshd.auth;version="7.4.0";x-internal:=true,
+ org.eclipse.jgit.internal.transport.sshd.pkcs11;version="7.4.0";x-internal:=true,
+ org.eclipse.jgit.internal.transport.sshd.proxy;version="7.4.0";x-friends:="org.eclipse.jgit.ssh.apache.test",
+ org.eclipse.jgit.signing.ssh;version="7.4.0";uses:="org.eclipse.jgit.lib",
+ org.eclipse.jgit.transport.sshd;version="7.4.0";
+ uses:="org.eclipse.jgit.transport,
+ org.apache.sshd.client.config.hosts,
+ org.apache.sshd.common.keyprovider,
+ org.eclipse.jgit.util,
+ org.apache.sshd.client.session,
+ org.apache.sshd.client.keyverifier",
+ org.eclipse.jgit.transport.sshd.agent;version="7.4.0"
+Import-Package: org.bouncycastle.jce.provider;version="[1.80.0,2.0.0)",
+ org.apache.sshd.agent;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.auth;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.auth.keyboard;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.auth.password;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.auth.pubkey;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.channel;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.config.hosts;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.config.keys;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.future;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.keyverifier;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.session;version="[2.15.0,2.16.0)",
+ org.apache.sshd.client.session.forward;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.auth;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.channel;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.cipher;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.compression;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.config.keys;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.config.keys.loader;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.config.keys.loader.openssh.kdf;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.config.keys.u2f;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.digest;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.forward;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.future;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.helpers;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.io;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.kex;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.kex.extension;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.kex.extension.parser;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.keyprovider;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.mac;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.random;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.session;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.session.helpers;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.signature;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.buffer;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.buffer.keys;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.closeable;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.io;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.io.der;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.io.functors;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.io.resource;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.logging;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.net;version="[2.15.0,2.16.0)",
+ org.apache.sshd.common.util.security;version="[2.15.0,2.16.0)",
+ org.apache.sshd.core;version="[2.15.0,2.16.0)",
+ org.apache.sshd.server.auth;version="[2.15.0,2.16.0)",
+ org.apache.sshd.sftp;version="[2.15.0,2.16.0)",
+ org.apache.sshd.sftp.client;version="[2.15.0,2.16.0)",
+ org.apache.sshd.sftp.common;version="[2.15.0,2.16.0)",
+ org.eclipse.jgit.annotations;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.api.errors;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.errors;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.fnmatch;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.internal.storage.file;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.internal.transport.ssh;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.lib;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.nls;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.transport;version="[7.4.0,7.5.0)",
+ org.eclipse.jgit.util;version="[7.4.0,7.5.0)",
+ org.slf4j;version="[1.7.0,3.0.0)"
diff --git a/org.eclipse.jgit.ssh.apache/META-INF/SOURCE-MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/SOURCE-MANIFEST.MF
new file mode 100644
index 0000000000..4858439ac6
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/META-INF/SOURCE-MANIFEST.MF
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+Bundle-ManifestVersion: 2
+Bundle-Name: org.eclipse.jgit.ssh.apache - Sources
+Bundle-SymbolicName: org.eclipse.jgit.ssh.apache.source
+Bundle-Vendor: Eclipse.org - JGit
+Bundle-Version: 7.4.0.qualifier
+Bundle-SCM: url=https://github.com/eclipse-jgit/jgit, connection=scm:git:https://eclipse.gerrithub.io/eclipse-jgit/jgit.git, developerConnection=scm:git:https://eclipse.gerrithub.io/a/eclipse-jgit/jgit.git
+Eclipse-SourceBundle: org.eclipse.jgit.ssh.apache;version="7.4.0.qualifier";roots="."
diff --git a/org.eclipse.jgit.ssh.apache/OSGI-INF/l10n/plugin.properties b/org.eclipse.jgit.ssh.apache/OSGI-INF/l10n/plugin.properties
new file mode 100644
index 0000000000..8358cc1a78
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/OSGI-INF/l10n/plugin.properties
@@ -0,0 +1,2 @@
+Bundle-Name=JGit SSH support based on Apache MINA sshd
+Bundle-Vendor=Eclipse JGit
diff --git a/org.eclipse.jgit.ssh.apache/README.md b/org.eclipse.jgit.ssh.apache/README.md
new file mode 100644
index 0000000000..b2911c688c
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/README.md
@@ -0,0 +1,160 @@
+# JGit SSH support via Apache MINA sshd
+
+This bundle provides an implementation of git transport over SSH implemented via
+[Apache MINA sshd](https://mina.apache.org/sshd-project/).
+
+## Service registration
+
+This bundle declares a service for the `java.util.ServiceLoader` for interface
+`org.eclipse.jgit.transport.ssh.SshSessionFactory`. The core JGit bundle uses the service
+loader to pick up an implementation of that interface.
+
+Note that JGit simply uses the first `SshSessionFactory` provided by the `ServiceLoader`.
+
+If the service loader cannot find the session factory, either ensure that the service
+declaration is on the Classpath of bundle `org.eclipse.jgit`, or set the factory explicitly
+(see below).
+
+In an OSGi environment, one might need a service loader bridge, or have a little OSGi
+fragment for bundle `org.eclipse.jgit` that puts the right service declaration onto the
+Classpath of that bundle. (OSGi fragments become part of the Classpath of their host
+bundle.)
+
+## Configuring an SSH implementation for JGit
+
+The simplest way to set an SSH implementation for JGit is to install it globally via
+`SshSessionFactory.setInstance()`. This instance will be used by JGit for all SSH
+connections by default.
+
+It is also possible to set the SSH implementation individually for any git command
+that needs a transport (`TransportCommand`) via a `org.eclipse.jgit.api.TransportConfigCallback`.
+
+To do so, set the wanted `SshSessionFactory` on the SSH transport, like:
+
+```java
+SshSessionFactory customFactory = ...; // Get it from wherever
+FetchCommand fetch = git.fetch()
+ .setTransportConfigCallback(transport -> {
+ if (transport instanceof SshTransport) {
+ ((SshTransport) transport).setSshSessionFactory(customFactory);
+ }
+ })
+ ...
+ .call();
+```
+
+## Support for SSH agents
+
+There exist two IETF draft RFCs for communication with an SSH agent:
+
+* an older [SSH1 protocol](https://tools.ietf.org/html/draft-ietf-secsh-agent-02) that can deal only with DSA and RSA keys, and
+* a newer [SSH2 protocol](https://tools.ietf.org/html/draft-miller-ssh-agent-04) (from OpenSSH).
+
+JGit only supports the newer OpenSSH protocol.
+
+Communication with an SSH agent can occur over any transport protocol, and different
+SSH agents may use different transports for local communication. JGit provides some
+transports via the [org.eclipse.jgit.ssh.apache.agent](../org.eclipse.jgit.ssh.apache.agent/README.md)
+fragment, which are discovered from `org.eclipse.jgit.ssh.apache` also via the `ServiceLoader` mechanism;
+the SPI (service provider interface) is `org.eclipse.jgit.transport.sshd.agent.ConnectorFactory`.
+
+If such a `ConnectorFactory` implementation is found, JGit may use an SSH agent. If none
+is available, JGit cannot communicate with an SSH agent, and will not attempt to use one.
+
+### SSH configurations for SSH agents
+
+There are several SSH properties that can be used in the `~/.ssh/config` file to configure
+the use of an SSH agent. For the details, see the [OpenBSD ssh-config documentation](https://man.openbsd.org/ssh_config.5).
+
+* **AddKeysToAgent** can be set to `no`, `yes`, or `ask`. If set to `yes`, keys will be added
+ to the agent if they're not yet in the agent. If set to `ask`, the user will be prompted
+ before doing so, and can opt out of adding the key. JGit also supports the additional
+ settings `confirm` and key lifetimes.
+* **IdentityAgent** can be set to choose which SSH agent to use, if there are several running.
+ It can also be set to `none` to explicitly switch off using an SSH agent at all.
+* **IdentitiesOnly** if set to `yes` and an SSH agent is used, only keys from the agent that are
+ also listed in an `IdentityFile` property and for which the public key is available in a
+ corresponding `*.pub` file will be considered. (It'll also switch off trying
+ default key names, such as `~/.ssh/id_rsa` or `~/.ssh/id_ed25519`; only keys listed explicitly
+ will be used.)
+
+### Limitations
+
+As mentioned above JGit only implements the newer OpenSSH protocol. OpenSSH fully implements this,
+but some other SSH agents only offer partial implementations. In particular on Windows, neither
+Pageant nor Win32-OpenSSH implement the `confirm` or lifetime constraints for `AddKeysToAgent`. With
+such SSH agents, these settings should not be used in `~/.ssh/config`. GPG's gpg-agent can be run
+with option `enable_putty_support` and can then be used as a Pageant replacement. gpg-agent appears
+to support these key constraints.
+
+OpenSSH does not implement ed448 keys, and neither does Apache MINA sshd, and hence such keys are
+not supported in JGit if its built-in SSH implementation is used. ed448 or other unsupported keys
+provided by an SSH agent are ignored.
+
+## PKCS#11 support
+
+JGit supports using PKCS#11 HSMs (Hardware Security Modules) such as YubiKey PIV for SSH
+authentication.
+
+Using such a PKCS#11 token for SSH authentication can be configured in `~/.ssh/config` with a
+configuration
+
+```
+ PCKS11Provider /absolute/path/to/vendor/library.so
+```
+
+instead of or in addition to `IdentityFile` or `IdentityAgent`. PKCS#11 keys are considered before
+keys from an SSH agent. If `IdentitiesOnly` is also set, only keys listed in `IdentityFile` for which
+the public key is available in a corresponding `*.pub` file are considered.
+
+If `PKCS11Provider` is not set, or is set to the value `none`, no PKCS#11 library is used.
+
+This is all as in OpenSSH.
+
+Keys from PKCS#11 tokens are never added to an SSH agent; the `AddKeysToAgent` configuration has
+no effect for PKCS#11 keys in JGit. It makes only sense if someone is using agent forwarding and
+it requires the SSH agent to understand the `SSH_AGENTC_ADD_SMARTCARD_KEY` command. It is unknown
+which SSH agents support this (OpenSSH does), the SSH library used by JGit has no API for it,
+and JGit doesn't do agent forwarding anyway. (To hop through servers to a git repository use
+`ProxyJump` instead.)
+
+JGit by default uses the first token (the default `slotListIndex` zero). The Java KeyStore or
+[Provider configuration](https://docs.oracle.com/en/java/javase/11/security/pkcs11-reference-guide1.html)
+does not seem to have any support for [RFC7512](https://www.rfc-editor.org/rfc/rfc7512) URIs
+to select the token. JGit provides a custom SSH configuration `PKCS11SlotListIndex` that can be
+set to the slot index of the token wanted. The value should be a non-negative integer. If not
+set or if negative, the first token (slot list index zero) is used. (Note that the value is the
+slot *index*, not the slot ID. Slot IDs are not necessarily stable.)
+
+If you *do* set `PKCS11SlotListIndex` anywhere in your configuration file, then you should also
+set at the very top of the `~/.ssh/config` file:
+
+```
+IgnoreUnknown PKCS11SlotListIndex
+```
+
+The `IgnoreUnknown` configuration tells OpenSSH to ignore configurations it doesn't know about.
+Without this option, OpenSSH will issue an error and exit if the config file contains
+`PKCS11SlotListIndex`. The `IgnoreUnknown` option is available in OpenSSH since version 6.3
+from 2013-09-13. See the [OpenSSH documentation](https://man.openbsd.org/ssh_config.5#IgnoreUnknown)
+for details.
+
+If a token has multiple certificates and keys, a specific one can be selected by exporting
+the public key to a file and then using `IdentitiesOnly` and an `IdentityFile` configuration.
+
+## Using a different SSH implementation
+
+To use a different SSH implementation:
+
+* Do not include this bundle in your product.
+* Include the bundle of the alternate implementation.
+ * If the service loader finds the alternate implementation, nothing more is needed.
+ * Otherwise ensure the service declaration from the other bundle is on the Classpath of bundle `org.eclipse.jgit`,
+ * or set the `SshSessionFactory` for JGit explicitly (see above).
+
+## Using an external SSH executable
+
+JGit has built-in support for not using any Java SSH implementation but an external SSH
+executable. To use an external SSH executable, set environment variable **GIT_SSH** to
+the path of the executable. JGit will create a sub-process to run the executable and
+communicate with this sub-process to perform the git operation.
diff --git a/org.eclipse.jgit.ssh.apache/about.html b/org.eclipse.jgit.ssh.apache/about.html
new file mode 100644
index 0000000000..f971af18d0
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/about.html
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="ISO-8859-1" ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1" />
+<title>Eclipse Distribution License - Version 1.0</title>
+<style type="text/css">
+ body {
+ size: 8.5in 11.0in;
+ margin: 0.25in 0.5in 0.25in 0.5in;
+ tab-interval: 0.5in;
+ }
+ p {
+ margin-left: auto;
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+ }
+ p.list {
+ margin-left: 0.5in;
+ margin-top: 0.05em;
+ margin-bottom: 0.05em;
+ }
+ .ubc-name {
+ margin-left: 0.5in;
+ white-space: pre;
+ }
+ </style>
+
+</head>
+
+<body lang="EN-US">
+
+<p><b>Eclipse Distribution License - v 1.0</b></p>
+
+<p>Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. </p>
+
+<p>All rights reserved.</p>
+<p>Redistribution and use in source and binary forms, with or without modification,
+ are permitted provided that the following conditions are met:
+<ul><li>Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer. </li>
+<li>Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution. </li>
+<li>Neither the name of the Eclipse Foundation, Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission. </li></ul>
+</p>
+<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.</p>
+
+<hr>
+<p><b>SHA-1 UbcCheck - MIT</b></p>
+
+<p>Copyright (c) 2017:</p>
+<div class="ubc-name">
+Marc Stevens
+Cryptology Group
+Centrum Wiskunde & Informatica
+P.O. Box 94079, 1090 GB Amsterdam, Netherlands
+marc@marc-stevens.nl
+</div>
+<div class="ubc-name">
+Dan Shumow
+Microsoft Research
+danshu@microsoft.com
+</div>
+<p>Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+</p>
+<ul><li>The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.</li></ul>
+<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.</p>
+
+</body>
+
+</html>
diff --git a/org.eclipse.jgit.ssh.apache/build.properties b/org.eclipse.jgit.ssh.apache/build.properties
new file mode 100644
index 0000000000..b483ecd96b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/build.properties
@@ -0,0 +1,7 @@
+source.. = src/,\
+ resources/
+output.. = bin/
+bin.includes = META-INF/,\
+ OSGI-INF/,\
+ .,\
+ about.html
diff --git a/org.eclipse.jgit.ssh.apache/manual_tests.txt b/org.eclipse.jgit.ssh.apache/manual_tests.txt
new file mode 100644
index 0000000000..ea3e59cfe0
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/manual_tests.txt
@@ -0,0 +1,45 @@
+Testing PKCS11 support
+----------------------
+
+# Install SoftHSM and OpenSC
+
+I got SoftHSM via MacPorts, and OpenSC from https://github.com/OpenSC/OpenSC#downloads
+
+You need both; softhsm2-util cannot import certificates.
+
+# Initialize SoftHSM
+
+$ softhsm2-util --init-token --slot 0 --label "TestToken" --pin 1234 --so-pin 4567
+The token has been initialized and is reassigned to slot 2006661923
+
+# Create a new RSA key and certificate
+
+$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -subj "/CN=MyCertTEST" -nodes
+
+# Import the RSA key pair into the SoftHSM token
+
+$ softhsm2-util --import key.pem --slot 2006661923 --label "testkey" --id 1212 --pin 1234
+
+# Convert the certificate to DER and import it into SoftHSM token
+
+$ openssl x509 -in cert.pem -out cert.der -outform DER
+$ pkcs11-tool --module /opt/local/lib/softhsm/libsofthsm2.so -l --id 1212 --label "testcert" -y cert -w cert.der --pin 1234
+
+# Export the RSA public key convert to PEM, and show in SSH format
+# (I'm sure this could be done simpler from the original key.pem, but what the heck.)
+
+pkcs11-tool --module /opt/local/lib/softhsm/libsofthsm2.so --slot 2006661923 --read-object --type pubkey --id 1212 -o key.der
+openssl rsa -pubin -inform DER -in key.der -outform PEM -out key.pub.pem
+ssh-keygen -f key.pub.pem -m pkcs8 -i
+
+# Install that public key at Gerrit (or your git server of choice)
+
+# Have an ~/.ssh/config with a host entry for your git server using the SoftHSM library as PKCS11 provider:
+
+Host gitserver
+Hostname git.eclipse.org
+Port 29418
+User ...
+PKCS11Provider /opt/local/lib/softhsm/libsofthsm2.so
+
+# Fetch from your git server! When asked for the PIN, enter 1234.
diff --git a/org.eclipse.jgit.ssh.apache/pom.xml b/org.eclipse.jgit.ssh.apache/pom.xml
new file mode 100644
index 0000000000..2d6cd39ae4
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/pom.xml
@@ -0,0 +1,232 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright (C) 2018, 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
+ http://www.eclipse.org/org/documents/edl-v10.php.
+
+ SPDX-License-Identifier: BSD-3-Clause
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit-parent</artifactId>
+ <version>7.4.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>org.eclipse.jgit.ssh.apache</artifactId>
+ <name>JGit - Apache sshd-based SSH support</name>
+
+ <description>
+ SSH support for JGit based on Apache MINA sshd
+ </description>
+
+ <properties>
+ <translate-qualifier/>
+ <source-bundle-manifest>${project.build.directory}/META-INF/SOURCE-MANIFEST.MF</source-bundle-manifest>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.eclipse.jgit</groupId>
+ <artifactId>org.eclipse.jgit</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.sshd</groupId>
+ <artifactId>sshd-osgi</artifactId>
+ <version>${apache-sshd-version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.sshd</groupId>
+ <artifactId>sshd-sftp</artifactId>
+ <version>${apache-sshd-version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.apache.sshd</groupId>
+ <artifactId>sshd-common</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>org.apache.sshd</groupId>
+ <artifactId>sshd-core</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <sourceDirectory>src/</sourceDirectory>
+
+ <resources>
+ <resource>
+ <directory>.</directory>
+ <includes>
+ <include>plugin.properties</include>
+ <include>about.html</include>
+ </includes>
+ </resource>
+ <resource>
+ <directory>resources/</directory>
+ </resource>
+ </resources>
+
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-antrun-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>translate-source-qualifier</id>
+ <phase>generate-resources</phase>
+ <configuration>
+ <target>
+ <copy file="META-INF/SOURCE-MANIFEST.MF" tofile="${source-bundle-manifest}" overwrite="true" />
+ <replace file="${source-bundle-manifest}">
+ <replacefilter token=".qualifier" value=".${commit.time.version}" />
+ </replace>
+ </target>
+ </configuration>
+ <goals>
+ <goal>run</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <inherited>true</inherited>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>jar</goal>
+ </goals>
+ <configuration>
+ <archive>
+ <manifestFile>${source-bundle-manifest}</manifestFile>
+ </archive>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <archive>
+ <manifestFile>${bundle-manifest}</manifestFile>
+ </archive>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <groupId>com.github.siom79.japicmp</groupId>
+ <artifactId>japicmp-maven-plugin</artifactId>
+ <version>${japicmp-version}</version>
+ <configuration>
+ <oldVersion>
+ <dependency>
+ <groupId>${project.groupId}</groupId>
+ <artifactId>${project.artifactId}</artifactId>
+ <version>${jgit-last-release-version}</version>
+ </dependency>
+ </oldVersion>
+ <newVersion>
+ <file>
+ <path>${project.build.directory}/${project.artifactId}-${project.version}.jar</path>
+ </file>
+ </newVersion>
+ <parameter>
+ <onlyModified>true</onlyModified>
+ <includes>
+ <include>org.eclipse.jgit.*</include>
+ </includes>
+ <excludes>
+ <exclude>*.internal.*</exclude>
+ </excludes>
+ <accessModifier>public</accessModifier>
+ <breakBuildOnModifications>false</breakBuildOnModifications>
+ <breakBuildOnBinaryIncompatibleModifications>false</breakBuildOnBinaryIncompatibleModifications>
+ <onlyBinaryIncompatible>false</onlyBinaryIncompatible>
+ <includeSynthetic>false</includeSynthetic>
+ <ignoreMissingClasses>false</ignoreMissingClasses>
+ <skipPomModules>true</skipPomModules>
+ </parameter>
+ <skip>false</skip>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>verify</phase>
+ <goals>
+ <goal>cmp</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+ <reporting>
+ <plugins>
+ <plugin>
+ <groupId>com.github.siom79.japicmp</groupId>
+ <artifactId>japicmp-maven-plugin</artifactId>
+ <version>${japicmp-version}</version>
+ <reportSets>
+ <reportSet>
+ <reports>
+ <report>cmp-report</report>
+ </reports>
+ </reportSet>
+ </reportSets>
+ <configuration>
+ <oldVersion>
+ <dependency>
+ <groupId>${project.groupId}</groupId>
+ <artifactId>${project.artifactId}</artifactId>
+ <version>${jgit-last-release-version}</version>
+ </dependency>
+ </oldVersion>
+ <newVersion>
+ <file>
+ <path>${project.build.directory}/${project.artifactId}-${project.version}.jar</path>
+ </file>
+ </newVersion>
+ <parameter>
+ <onlyModified>true</onlyModified>
+ <includes>
+ <include>org.eclipse.jgit.*</include>
+ </includes>
+ <excludes>
+ <exclude>*.internal.*</exclude>
+ </excludes>
+ <accessModifier>public</accessModifier>
+ <breakBuildOnModifications>false</breakBuildOnModifications>
+ <breakBuildOnBinaryIncompatibleModifications>false</breakBuildOnBinaryIncompatibleModifications>
+ <onlyBinaryIncompatible>false</onlyBinaryIncompatible>
+ <includeSynthetic>false</includeSynthetic>
+ <ignoreMissingClasses>false</ignoreMissingClasses>
+ <skipPomModules>true</skipPomModules>
+ </parameter>
+ <skip>false</skip>
+ </configuration>
+ </plugin>
+ </plugins>
+ </reporting>
+</project>
diff --git a/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory
new file mode 100644
index 0000000000..4a0f553c81
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignatureVerifierFactory
@@ -0,0 +1 @@
+org.eclipse.jgit.signing.ssh.SshSignatureVerifierFactory \ No newline at end of file
diff --git a/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory
new file mode 100644
index 0000000000..80f22c055f
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.lib.SignerFactory
@@ -0,0 +1 @@
+org.eclipse.jgit.signing.ssh.SshSignerFactory \ No newline at end of file
diff --git a/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory
new file mode 100644
index 0000000000..8289411149
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/resources/META-INF/services/org.eclipse.jgit.transport.SshSessionFactory
@@ -0,0 +1 @@
+org.eclipse.jgit.transport.sshd.SshdSessionFactory
diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
new file mode 100644
index 0000000000..773c4b9432
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
@@ -0,0 +1,194 @@
+authenticationCanceled=SSH authentication canceled: no password given
+authenticationOnClosedSession=Authentication canceled: session is already closing or closed
+authGssApiAttempt={0}: trying mechanism OID {1}
+authGssApiExhausted={0}: no more mechanisms to try
+authGssApiFailure={0}: server refused authentication; mechanism {1}
+authGssApiNotTried={0}: not tried
+authGssApiPartialSuccess={0}: partial success with mechanism OID {1}, continue with authentication methods {2}
+authPasswordAttempt={0}: attempt {1}
+authPasswordChangeAttempt={0}: attempt {1} with password change
+authPasswordExhausted={0}: no more attempts
+authPasswordFailure={0}: server refused (wrong password)
+authPasswordNotTried={0}: not tried
+authPasswordPartialSuccess={0}: partial success, continue with authentication methods {1}
+authPubkeyAttempt={0}: trying {1} key {2} with signature type {3}
+authPubkeyAttemptAgent={0}: trying {1} key {2} from SSH agent with signature type {3}
+authPubkeyExhausted={0}: no more keys to try
+authPubkeyFailure={0}: server refused {1} key {2}
+authPubkeyNoKeys={0}: no keys to try
+authPubkeyPartialSuccess={0}: partial success for {1} key {2}, continue with authentication methods {3}
+cannotReadPublicKey=Cannot read public key from file {0}
+closeListenerFailed=Ssh session close listener failed
+configInvalidPath=Invalid path in ssh config key {0}: {1}
+configInvalidPattern=Invalid pattern in ssh config key {0}: {1}
+configInvalidPositive=Ssh config entry {0} must be a strictly positive number but is ''{1}''
+configInvalidProxyJump=Ssh config, host ''{0}'': Cannot parse ProxyJump ''{1}''
+configNoKnownAlgorithms=Ssh config ''{0}'' ''{1}'' resulted in empty list (none known, or all known removed); using default.
+configProxyJumpNotSsh=Non-ssh URI in ProxyJump ssh config
+configProxyJumpWithPath=ProxyJump ssh config: jump host specification must not have a path
+configUnknownAlgorithm=Ssh config {0}: ignoring unknown algorithm ''{1}'' in {2} {3}
+ftpCloseFailed=Closing the SFTP channel failed
+gssapiFailure=GSS-API error for mechanism OID {0}
+gssapiInitFailure=GSS-API initialization failure for mechanism {0}
+gssapiUnexpectedMechanism=Server {0} replied with unknown mechanism name ''{1}'' in {2} authentication
+gssapiUnexpectedMessage=Received unexpected ssh message {1} in {0} authentication
+identityFileCannotDecrypt=Given passphrase cannot read identity {0}
+identityFileNoKey=No keys found in identity {0}
+identityFileMultipleKeys=Multiple key pairs found in identity {0}
+identityFileNotFound=Skipping identity ''{0}'': file not found
+identityFileUnsupportedFormat=Unsupported format in identity {0}
+invalidSignatureAlgorithm=Signature algorithm ''{0}'' is not valid for a key of type ''{1}''
+kexServerKeyInvalid=Server key did not validate
+keyEncryptedMsg=''{0}'' needs a passphrase to be read.
+keyEncryptedPrompt=Passphrase
+keyEncryptedRetry=''{0}'' could not be read. Enter the passphrase again.
+keyLoadFailed=Could not load ''{0}''
+knownHostsCouldNotUpdate=Could not update known hosts file {0}
+knownHostsFileLockedUpdate=Could not update known hosts file (locked) {0}
+knownHostsFileReadFailed=Failed to read known hosts file {0}
+knownHostsInvalidLine=Known hosts file {0} contains invalid line {1}
+knownHostsInvalidPath=Invalid path for known hosts file {0}
+knownHostsKeyFingerprints=The {0} key''s fingerprints are:
+knownHostsModifiedKeyAcceptPrompt=Accept this key and continue connecting all the same?
+knownHostsModifiedKeyDenyMsg=To resolve this add the correct host key to your known hosts file {0}
+knownHostsModifiedKeyStorePrompt=If so, also store the new key?
+knownHostsModifiedKeyWarning=WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n\
+The connection might be compromised (man-in-the-middle attack).\n\
+It is also possible that the {0} key of the host has just been changed.\n\
+The expected {1} key for host ''{2}'' has the fingerprints:\n\
+{3}\n\
+{4}\n\
+The {0} key actually received has the fingerprints:\n\
+{5}\n\
+{6}
+knownHostsRevokedCertificateMsg=Host ''{0}'' sent a certificate with a CA key that is marked as revoked in the known hosts file {1}.
+knownHostsRevokedKeyMsg=Host ''{0}'' sent a key that is marked as revoked in the known hosts file {1}.
+knownHostsUnknownKeyMsg=The authenticity of host ''{0}'' cannot be established.
+knownHostsUnknownKeyPrompt=Accept and store this key, and continue connecting?
+knownHostsUnknownKeyType=Cannot read server key from known hosts file {0}; line {1}
+knownHostsUserAskCreationMsg=File {0} does not exist.
+knownHostsUserAskCreationPrompt=Create file {0} ?
+loginDenied=Cannot log in at {0}:{1}
+passwordPrompt=Password
+pkcs11Error=ERROR: {0}
+pkcs11FailedInstantiation=HostConfig for host {0} (hostname {1}): could not instantiate {2} {3}
+pkcs11GeneralMessage=Java reported for PKCS#11 token {0}: {1}
+pkcs11NoKeys=HostConfig for host {0} (hostname {1}) {2} {3} did not provide any keys
+pkcs11NonExisting=HostConfig for host {0} (hostname {1}) {2} {3} does not exist or is not a file
+pkcs11NotAbsolute=HostConfig for host {0} (hostname {1}) {2} {3} is not an absolute path
+pkcs11Unsupported=HostConfig for host {0} (hostname {1}) {2} {3}: PKCS#11 is not supported
+pkcs11Warning=WARNING: {0}
+proxyCannotAuthenticate=Cannot authenticate to proxy {0}
+proxyHttpFailure=HTTP Proxy connection to {0} failed with code {1}: {2}
+proxyHttpInvalidUserName=HTTP proxy connection {0} with invalid user name; must not contain colons: {1}
+proxyHttpUnexpectedReply=Unexpected HTTP proxy response from {0}: {1}
+proxyHttpUnspecifiedFailureReason=unspecified reason
+proxyJumpAbort=ProxyJump chain too long at {0}
+proxyPasswordPrompt=Proxy password
+proxySocksAuthenticationFailed=Authentication to SOCKS5 proxy {0} failed
+proxySocksFailureForbidden=SOCKS5 proxy {0}: connection to {1} not allowed by ruleset
+proxySocksFailureGeneral=SOCKS5 proxy {0}: general failure
+proxySocksFailureHostUnreachable=SOCKS5 proxy {0}: host unreachable {1}
+proxySocksFailureNetworkUnreachable=SOCKS5 proxy {0}: network unreachable {1}
+proxySocksFailureRefused=SOCKS5 proxy {0}: connection refused {1}
+proxySocksFailureTTL=TTL expired in SOCKS5 proxy connection {0}
+proxySocksFailureUnspecified=Unspecified failure in SOCKS5 proxy connection {0}
+proxySocksFailureUnsupportedAddress=SOCKS5 proxy {0} does not support address type
+proxySocksFailureUnsupportedCommand=SOCKS5 proxy {0} does not support CONNECT command
+proxySocksGssApiFailure=Cannot authenticate with GSS-API to SOCKS5 proxy {0}
+proxySocksGssApiMessageTooShort=SOCKS5 proxy {0} sent too short message
+proxySocksGssApiUnknownMessage=SOCKS5 proxy {0} sent unexpected GSS-API message type, expected 1, got {1}
+proxySocksGssApiVersionMismatch=SOCKS5 proxy {0} sent wrong GSS-API version number, expected 1, got {1}
+proxySocksNoRemoteHostName=Could not send remote address {0}
+proxySocksPasswordTooLong=Password for proxy {0} must be at most 255 bytes long, is {1} bytes
+proxySocksUnexpectedMessage=Unexpected message received from SOCKS5 proxy {0}; client state {1}: {2}
+proxySocksUnexpectedVersion=Expected SOCKS version 5, got {0}
+proxySocksUsernameTooLong=User name for proxy {0} must be at most 255 bytes long, is {1} bytes: {2}
+pubkeyAuthAddKeyToAgentError=Could not add {0} key with fingerprint {1} to the SSH agent
+pubkeyAuthAddKeyToAgentQuestion=Add the {0} key with fingerprint {1} to the SSH agent?
+pubkeyAuthWrongCommand=Public key authentication received unknown SSH command {0} from {1} ({2})
+pubkeyAuthWrongKey=Public key authentication received wrong key; sent {0}, got back {1} from {2} ({3})
+pubkeyAuthWrongSignatureAlgorithm=Public key authentication requested signature type {0} but got back {1} from {2} ({3})
+serverIdNotReceived=No server identification received within {0} bytes
+serverIdTooLong=Server identification is longer than 255 characters (including line ending): {0}
+serverIdWithNul=Server identification contains a NUL character: {0}
+sessionCloseFailed=Closing the session failed
+sessionWithoutUsername=SSH session created without user name; cannot authenticate
+sshAgentEdDSAFormatError=Cannot add ed25519 key to the SSH agent because it is encoded as {0} instead of PKCS#8
+sshAgentPayloadLengthError=Expected {0,choice,0#no bytes|1#one byte|1<{0} bytes} but got {1}
+sshAgentReplyLengthError=Invalid SSH agent reply message length {0} after command {1}
+sshAgentReplyUnexpected=Unexpected reply from ssh-agent: {0}
+sshAgentShortReadBuffer=Short read from SSH agent
+sshAgentUnknownKey=SSH agent delivered a key that cannot be handled
+sshAgentWrongKeyLength=SSH agent delivered illogical key length {0} at offset {1} in buffer of length {2}
+sshAgentWrongNumberOfKeys=Invalid number of SSH agent keys: {0}
+sshClosingDown=Apache MINA sshd session factory is closing down; cannot create new ssh sessions on this factory
+sshCommandTimeout={0} timed out after {1} seconds while opening the channel
+sshProcessStillRunning={0} is not yet completed, cannot get exit code
+sshProxySessionCloseFailed=Error while closing proxy session {0}
+signAllowedSignersCertAuthorityError=Garbage after cert-authority
+signAllowedSignersEmptyIdentity=Identities contains an empty identity; check for spurious extra commas: {0}
+signAllowedSignersEmptyNamespaces=Empty namespaces= is not allowed; to allow a key for any namespace, omit the namespaces option
+signAllowedSignersFormatError=Cannot parse allowed signers file {0}, problem at line {1}: {2}
+signAllowedSignersInvalidDate=Cannot parse valid-before or valid-after date {0}
+signAllowedSignersLineFormat=Invalid line format
+signAllowedSignersMultiple={0} is allowed only once
+signAllowedSignersNoIdentities=Line has no identity patterns
+signAllowedSignersPublicKeyParsing=Cannot parse public key {0}
+signAllowedSignersUnterminatedQuote=Unterminated double quote
+signCertAlgorithmMismatch=Certificate of type {0} with CA key {1} uses an incompatible signature algorithm {2}
+signCertAlgorithmUnknown=Certificate with CA key {0} is signed with an unknown algorithm {1}
+signCertificateExpired=Expired certificate with CA key {0}
+signCertificateInvalid=Certificate signature does not match on certificate with CA key {0}
+signCertificateNotForName=Certificate with CA key {0} does not apply for name ''{1}''
+signCertificateRevoked=Certificate with CA key {0} was revoked
+signCertificateTooEarly=Certificate with CA key {0} was not valid yet
+signCertificateWithoutPrincipals=Certificate with CA key {0} has no principals; identities from gpg.ssh.allowedSignersFile: {1}
+signDefaultKeyEmpty=git.ssh.defaultKeyCommand {0} returned no key
+signDefaultKeyFailed=git.ssh.defaultKeyCommand {0} failed with exit code {1}\n{2}
+signDefaultKeyInterrupted=git.ssh.defaultKeyCommand {0} was interrupted
+signGarbageAtEnd=SSH signature has extra bytes at the end
+signInvalidAlgorithm=SSH signature has invalid signature algorithm {0}
+signInvalidKeyDSA=SSH signatures with DSA keys or certificates are not supported; use a different signing key.
+signInvalidMagic=SSH signature does not start with "SSHSIG"
+signInvalidNamespace=Namespace of SSH signature should be ''git'' but is ''{0}''
+signInvalidSignature=SSH signature is invalid: {0}
+signInvalidVersion=Cannot verify signature with version {0}
+signKeyExpired=Expired key used for SSH signature
+signKeyRevoked=Key used for the SSH signature was revoked
+signKeyTooEarly=Key used for the SSH signature was not valid yet
+signKrlBlobLeftover=gpg.ssh.revocationFile has invalid blob section {0} with {1} leftover bytes
+signKrlBlobLengthInvalid=gpg.ssh.revocationFile has invalid blob length {1} in section {0}
+signKrlBlobLengthInvalidExpected=gpg.ssh.revocationFile has invalid blob length {1} (expected {2}) in section {0}
+signKrlCaKeyLengthInvalid=gpg.ssh.revocationFile has invalid CA key length {0} in certificates section
+signKrlCertificateLeftover=gpg.ssh.revocationFile has invalid certificates section with {0} leftover bytes
+signKrlCertificateSubsectionLeftover=gpg.ssh.revocationFile has invalid certificates subsection with {0} leftover bytes
+signKrlCertificateSubsectionLength=gpg.ssh.revocationFile has invalid certificates subsection length {0}
+signKrlEmptyRange=gpg.ssh.revocationFile has an empty range of certificate serial numbers
+signKrlInvalidBitSetLength=gpg.ssh.revocationFile has invalid certificate serial number bit set length {0}
+signKrlInvalidKeyIdLength=gpg.ssh.revocationFile has invalid certificate key ID length {0}
+signKrlInvalidMagic=gpg.ssh.revocationFile is not a binary OpenSSH key revocation list
+signKrlInvalidReservedLength=gpg.ssh.revocationFile has an invalid reserved string length {0}
+signKrlInvalidVersion=gpg.ssh.revocationFile: cannot read KRLs with FORMAT_VERSION {0}
+signKrlNoCertificateSubsection=gpg.ssh.revocationFile has certificate section without subsections
+signKrlSerialZero=gpg.ssh.revocationFile: certificate serial number zero cannot be revoked
+signKrlShortRange=gpg.ssh.revocationFile: short certificate serial number range, need at least 8 more bytes, got only {0}
+signKrlUnknownSection=gpg.ssh.revocationFile has an unknown section type {0}
+signKrlUnknownSubsection=gpg.ssh.revocationFile has an unknown certificates subsection type {0}
+signLogFailure=SSH signature verification failed
+signMismatchedSignatureAlgorithm=SSH signature made with an ''{0}'' key has incompatible signature algorithm ''{1}''
+signNoAgent=No connector for ssh-agent found; maybe include org.eclipse.jgit.ssh.apache.agent in the application.
+signNoPrincipalMatched=No principal matched in gpg.ssh.allowedSignersFile
+signNoPublicKey=No public key found with signing key {0}
+signNoSigningKey=Git config user.signingKey or gpg.ssh.defaultKeyCommand must be set for SSH signing.
+signNotUserCertificate=Certificate with CA key {0} used for the SSH signature is not a user certificate.
+signPublicKeyError=Cannot read public key {0}
+signSeeLog=SSH signature verification failed; see the log for details
+signSignatureError=Could not create the signature
+signStderr=Cannot read stderr
+signTooManyPrivateKeys=Private key file {0} must contain exactly one private key
+signTooManyPublicKeys=Public key file {0} must contain exactly one public key
+signUnknownHashAlgorithm=SSH Signature has an unknown hash algorithm {0}
+signUnknownSignatureAlgorithm=SSH Signature has an unknown signature algorithm {0}
+signWrongNamespace=Key may not be used in namespace "{0}".
+unknownProxyProtocol=Ignoring unknown proxy protocol {0} \ No newline at end of file
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java
new file mode 100644
index 0000000000..80b171f216
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/AllowedSigners.java
@@ -0,0 +1,530 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StreamCorruptedException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import java.text.MessageFormat;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.time.temporal.TemporalAccessor;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.util.io.ModifiableFileWatcher;
+import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.signing.ssh.VerificationException;
+import org.eclipse.jgit.util.StringUtils;
+import org.eclipse.jgit.util.SystemReader;
+
+/**
+ * Encapsulates the allowed signers handling.
+ */
+final class AllowedSigners extends ModifiableFileWatcher {
+
+ private static final String CERT_AUTHORITY = "cert-authority"; //$NON-NLS-1$
+
+ private static final String NAMESPACES = "namespaces="; //$NON-NLS-1$
+
+ private static final String VALID_AFTER = "valid-after="; //$NON-NLS-1$
+
+ private static final String VALID_BEFORE = "valid-before="; //$NON-NLS-1$
+
+ private static final DateTimeFormatter SSH_DATE_FORMAT = new DateTimeFormatterBuilder()
+ .appendValue(ChronoField.YEAR, 4)
+ .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+ .appendValue(ChronoField.DAY_OF_MONTH, 2)
+ .optionalStart()
+ .appendValue(ChronoField.HOUR_OF_DAY, 2)
+ .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+ .optionalStart()
+ .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+ .toFormatter(Locale.ROOT);
+
+ private static final Predicate<AllowedEntry> CERTIFICATES = AllowedEntry::isCA;
+
+ private static final Predicate<AllowedEntry> PLAIN_KEYS = Predicate
+ .not(CERTIFICATES);
+
+ @SuppressWarnings("ArrayRecordComponent")
+ static record AllowedEntry(String[] identities, boolean isCA,
+ String[] namespaces, Instant validAfter, Instant validBefore,
+ String key) {
+ // Empty
+
+ @Override
+ public final boolean equals(Object any) {
+ if (this == any) {
+ return true;
+ }
+ if (any == null || !(any instanceof AllowedEntry)) {
+ return false;
+ }
+ AllowedEntry other = (AllowedEntry) any;
+ return isCA == other.isCA
+ && Arrays.equals(identities, other.identities)
+ && Arrays.equals(namespaces, other.namespaces)
+ && Objects.equals(validAfter, other.validAfter)
+ && Objects.equals(validBefore, other.validBefore)
+ && Objects.equals(key, other.key);
+ }
+
+ @Override
+ public final int hashCode() {
+ int hash = Boolean.hashCode(isCA);
+ hash = hash * 31 + Arrays.hashCode(identities);
+ hash = hash * 31 + Arrays.hashCode(namespaces);
+ return hash * 31 + Objects.hash(validAfter, validBefore, key);
+ }
+ }
+
+ private static record State(Map<String, List<AllowedEntry>> entries) {
+ // Empty
+ }
+
+ private State state;
+
+ public AllowedSigners(Path path) {
+ super(path);
+ state = new State(new HashMap<>());
+ }
+
+ public String isAllowed(PublicKey key, String namespace, String name,
+ Instant time) throws IOException, VerificationException {
+ State currentState = refresh();
+ PublicKey keyToCheck = key;
+ if (key instanceof OpenSshCertificate certificate) {
+ AllowedEntry entry = find(currentState, certificate.getCaPubKey(),
+ namespace, name, time, CERTIFICATES);
+ if (entry != null) {
+ Collection<String> principals = certificate.getPrincipals();
+ if (principals.isEmpty()) {
+ // According to the OpenSSH documentation, a certificate
+ // without principals is valid for anyone.
+ //
+ // See https://man.openbsd.org/ssh-keygen.1#CERTIFICATES .
+ //
+ // However, the same documentation also says that a name
+ // must match both the entry's patterns and be listed in the
+ // certificate's principals.
+ //
+ // See https://man.openbsd.org/ssh-keygen.1#ALLOWED_SIGNERS
+ //
+ // git/OpenSSH considers signatures made by such
+ // certificates untrustworthy.
+ String identities;
+ if (!StringUtils.isEmptyOrNull(name)) {
+ // The name must have matched entry.identities.
+ identities = name;
+ } else {
+ identities = Arrays.stream(entry.identities())
+ .collect(Collectors.joining(",")); //$NON-NLS-1$
+ }
+ throw new VerificationException(false, MessageFormat.format(
+ SshdText.get().signCertificateWithoutPrincipals,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()),
+ identities));
+ }
+ if (!StringUtils.isEmptyOrNull(name)) {
+ if (!principals.contains(name)) {
+ throw new VerificationException(false,
+ MessageFormat.format(SshdText
+ .get().signCertificateNotForName,
+ KeyUtils.getFingerPrint(
+ certificate.getCaPubKey()),
+ name));
+ }
+ return name;
+ }
+ // Filter the principals listed in the certificate by
+ // the patterns defined in the file.
+ Set<String> filtered = new LinkedHashSet<>();
+ List<String> patterns = Arrays.asList(entry.identities());
+ for (String principal : principals) {
+ if (OpenSshConfigFile.patternMatch(patterns, principal)) {
+ filtered.add(principal);
+ }
+ }
+ return filtered.stream().collect(Collectors.joining(",")); //$NON-NLS-1$
+ }
+ // Certificate not found. git/OpenSSH considers this untrustworthy,
+ // even if the certified key itself might be listed.
+ return null;
+ // Alternative: go check for the certified key itself:
+ // keyToCheck = certificate.getCertPubKey();
+ }
+ AllowedEntry entry = find(currentState, keyToCheck, namespace, name,
+ time, PLAIN_KEYS);
+ if (entry != null) {
+ if (!StringUtils.isEmptyOrNull(name)) {
+ // The name must have matched entry.identities.
+ return name;
+ }
+ // No name given, but we consider the key valid: report the
+ // identities.
+ return Arrays.stream(entry.identities())
+ .collect(Collectors.joining(",")); //$NON-NLS-1$
+ }
+ return null;
+ }
+
+ private AllowedEntry find(State current, PublicKey key,
+ String namespace, String name, Instant time,
+ Predicate<AllowedEntry> filter)
+ throws VerificationException {
+ String k = PublicKeyEntry.toString(key);
+ VerificationException v = null;
+ List<AllowedEntry> candidates = current.entries().get(k);
+ if (candidates == null) {
+ return null;
+ }
+ for (AllowedEntry entry : candidates) {
+ if (!filter.test(entry)) {
+ continue;
+ }
+ if (name != null && !OpenSshConfigFile
+ .patternMatch(Arrays.asList(entry.identities()), name)) {
+ continue;
+ }
+ if (entry.namespaces() != null) {
+ if (!OpenSshConfigFile.patternMatch(
+ Arrays.asList(entry.namespaces()),
+ namespace)) {
+ if (v == null) {
+ v = new VerificationException(false,
+ MessageFormat.format(
+ SshdText.get().signWrongNamespace,
+ KeyUtils.getFingerPrint(key),
+ namespace));
+ }
+ continue;
+ }
+ }
+ if (time != null) {
+ if (entry.validAfter() != null
+ && time.isBefore(entry.validAfter())) {
+ if (v == null) {
+ v = new VerificationException(true,
+ MessageFormat.format(
+ SshdText.get().signKeyTooEarly,
+ KeyUtils.getFingerPrint(key)));
+ }
+ continue;
+ } else if (entry.validBefore() != null
+ && time.isAfter(entry.validBefore())) {
+ if (v == null) {
+ v = new VerificationException(true,
+ MessageFormat.format(
+ SshdText.get().signKeyTooEarly,
+ KeyUtils.getFingerPrint(key)));
+ }
+ continue;
+ }
+ }
+ return entry;
+ }
+ if (v != null) {
+ throw v;
+ }
+ return null;
+ }
+
+ private synchronized State refresh() throws IOException {
+ if (checkReloadRequired()) {
+ updateReloadAttributes();
+ try {
+ state = reload(getPath());
+ } catch (NoSuchFileException e) {
+ // File disappeared
+ resetReloadAttributes();
+ state = new State(new HashMap<>());
+ }
+ }
+ return state;
+ }
+
+ private static State reload(Path path) throws IOException {
+ Map<String, List<AllowedEntry>> entries = new HashMap<>();
+ try (BufferedReader r = Files.newBufferedReader(path,
+ StandardCharsets.UTF_8)) {
+ String line;
+ for (int lineNumber = 1;; lineNumber++) {
+ line = r.readLine();
+ if (line == null) {
+ break;
+ }
+ line = line.strip();
+ try {
+ AllowedEntry entry = parseLine(line);
+ if (entry != null) {
+ entries.computeIfAbsent(entry.key(),
+ k -> new ArrayList<>()).add(entry);
+ }
+ } catch (IOException | RuntimeException e) {
+ throw new IOException(MessageFormat.format(
+ SshdText.get().signAllowedSignersFormatError, path,
+ Integer.toString(lineNumber), line), e);
+ }
+ }
+ }
+ return new State(entries);
+ }
+
+ private static boolean matches(String src, String other, int offset) {
+ return src.regionMatches(true, offset, other, 0, other.length());
+ }
+
+ // Things below have package visibility for testing.
+
+ static AllowedEntry parseLine(String line)
+ throws IOException {
+ if (StringUtils.isEmptyOrNull(line) || line.charAt(0) == '#') {
+ return null;
+ }
+ int length = line.length();
+ if ((matches(line, CERT_AUTHORITY, 0)
+ && CERT_AUTHORITY.length() < length
+ && Character.isWhitespace(line.charAt(CERT_AUTHORITY.length())))
+ || matches(line, NAMESPACES, 0)
+ || matches(line, VALID_AFTER, 0)
+ || matches(line, VALID_BEFORE, 0)) {
+ throw new StreamCorruptedException(
+ SshdText.get().signAllowedSignersNoIdentities);
+ }
+ int i = 0;
+ while (i < length && !Character.isWhitespace(line.charAt(i))) {
+ i++;
+ }
+ if (i >= length) {
+ throw new StreamCorruptedException(SshdText.get().signAllowedSignersLineFormat);
+ }
+ String[] identities = line.substring(0, i).split(","); //$NON-NLS-1$
+ if (Arrays.stream(identities).anyMatch(String::isEmpty)) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersEmptyIdentity,
+ line.substring(0, i)));
+ }
+ // Parse the options
+ i++;
+ boolean isCA = false;
+ List<String> namespaces = null;
+ Instant validAfter = null;
+ Instant validBefore = null;
+ while (i < length) {
+ // Skip whitespace
+ if (Character.isSpaceChar(line.charAt(i))) {
+ i++;
+ continue;
+ }
+ if (matches(line, CERT_AUTHORITY, i)) {
+ i += CERT_AUTHORITY.length();
+ isCA = true;
+ if (!Character.isWhitespace(line.charAt(i))) {
+ throw new StreamCorruptedException(SshdText.get().signAllowedSignersCertAuthorityError);
+ }
+ i++;
+ } else if (matches(line, NAMESPACES, i)) {
+ if (namespaces != null) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersMultiple,
+ NAMESPACES));
+ }
+ i += NAMESPACES.length();
+ Dequoted parsed = dequote(line, i);
+ i = parsed.after();
+ String ns = parsed.value();
+ String[] items = ns.split(","); //$NON-NLS-1$
+ namespaces = new ArrayList<>(items.length);
+ for (int j = 0; j < items.length; j++) {
+ String n = items[j].strip();
+ if (!n.isEmpty()) {
+ namespaces.add(n);
+ }
+ }
+ if (namespaces.isEmpty()) {
+ throw new StreamCorruptedException(
+ SshdText.get().signAllowedSignersEmptyNamespaces);
+ }
+ } else if (matches(line, VALID_AFTER, i)) {
+ if (validAfter != null) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersMultiple,
+ VALID_AFTER));
+ }
+ i += VALID_AFTER.length();
+ Dequoted parsed = dequote(line, i);
+ i = parsed.after();
+ validAfter = parseDate(parsed.value());
+ } else if (matches(line, VALID_BEFORE, i)) {
+ if (validBefore != null) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersMultiple,
+ VALID_BEFORE));
+ }
+ i += VALID_BEFORE.length();
+ Dequoted parsed = dequote(line, i);
+ i = parsed.after();
+ validBefore = parseDate(parsed.value());
+ } else {
+ break;
+ }
+ }
+ // Now we should be at the key
+ String key = parsePublicKey(line, i);
+ return new AllowedEntry(identities, isCA,
+ namespaces == null ? null : namespaces.toArray(new String[0]),
+ validAfter, validBefore, key);
+ }
+
+ static String parsePublicKey(String s, int from)
+ throws StreamCorruptedException {
+ int i = from;
+ int length = s.length();
+ while (i < length && Character.isWhitespace(s.charAt(i))) {
+ i++;
+ }
+ if (i >= length) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersPublicKeyParsing,
+ s.substring(from)));
+ }
+ int start = i;
+ while (i < length && !Character.isWhitespace(s.charAt(i))) {
+ i++;
+ }
+ if (i >= length) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersPublicKeyParsing,
+ s.substring(start)));
+ }
+ int endOfKeyType = i;
+ i = endOfKeyType + 1;
+ while (i < length && Character.isWhitespace(s.charAt(i))) {
+ i++;
+ }
+ int startOfKey = i;
+ while (i < length && !Character.isWhitespace(s.charAt(i))) {
+ i++;
+ }
+ if (i == startOfKey) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersPublicKeyParsing,
+ s.substring(start)));
+ }
+ String keyType = s.substring(start, endOfKeyType);
+ String key = s.substring(startOfKey, i);
+ if (!key.startsWith("AAAA")) { //$NON-NLS-1$
+ // base64 encoded SSH keys always start with four 'A's.
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signAllowedSignersPublicKeyParsing,
+ s.substring(start)));
+ }
+ return keyType + ' ' + s.substring(startOfKey, i);
+ }
+
+ static Instant parseDate(String input) {
+ // Allowed formats are YYYYMMDD[Z] or YYYYMMDDHHMM[SS][Z]. If 'Z', it's
+ // UTC, otherwise local time.
+ String timeSpec = input;
+ int length = input.length();
+ if (length < 8) {
+ throw new IllegalArgumentException(MessageFormat.format(
+ SshdText.get().signAllowedSignersInvalidDate, input));
+ }
+ boolean isUTC = false;
+ if (timeSpec.charAt(length - 1) == 'Z') {
+ isUTC = true;
+ timeSpec = timeSpec.substring(0, length - 1);
+ }
+ LocalDateTime time;
+ TemporalAccessor temporalAccessor = SSH_DATE_FORMAT.parseBest(timeSpec,
+ LocalDateTime::from, LocalDate::from);
+ if (temporalAccessor instanceof LocalDateTime) {
+ time = (LocalDateTime) temporalAccessor;
+ } else {
+ time = ((LocalDate) temporalAccessor).atStartOfDay();
+ }
+ if (isUTC) {
+ return time.atOffset(ZoneOffset.UTC).toInstant();
+ }
+ ZoneId tz = SystemReader.getInstance().getTimeZoneId();
+ return time.atZone(tz).toInstant();
+ }
+
+ // OpenSSH uses the backslash *only* to quote the double-quote.
+ static Dequoted dequote(String line, int from) {
+ int length = line.length();
+ int i = from;
+ if (line.charAt(i) == '"') {
+ boolean quoted = false;
+ i++;
+ StringBuilder b = new StringBuilder();
+ while (i < length) {
+ char ch = line.charAt(i);
+ if (ch == '"') {
+ if (quoted) {
+ b.append(ch);
+ quoted = false;
+ } else {
+ break;
+ }
+ } else if (ch == '\\') {
+ quoted = true;
+ } else {
+ if (quoted) {
+ b.append('\\');
+ }
+ b.append(ch);
+ quoted = false;
+ }
+ i++;
+ }
+ if (i >= length) {
+ throw new IllegalArgumentException(
+ SshdText.get().signAllowedSignersUnterminatedQuote);
+ }
+ return new Dequoted(b.toString(), i + 1);
+ }
+ while (i < length && !Character.isWhitespace(line.charAt(i))) {
+ i++;
+ }
+ return new Dequoted(line.substring(from, i), i);
+ }
+
+ static record Dequoted(String value, int after) {
+ // Empty
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java
new file mode 100644
index 0000000000..6b19eb3295
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshBinaryKrl.java
@@ -0,0 +1,491 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StreamCorruptedException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.util.buffer.BufferUtils;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.api.errors.JGitInternalException;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.util.IO;
+import org.eclipse.jgit.util.StringUtils;
+
+/**
+ * An implementation of OpenSSH binary format key revocation lists (KRLs).
+ *
+ * @see <a href=
+ * "https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.krl">PROTOCOL.krl</a>
+ */
+class OpenSshBinaryKrl {
+
+ /**
+ * The "magic" bytes at the start of an OpenSSH binary KRL.
+ */
+ static final byte[] MAGIC = { 'S', 'S', 'H', 'K', 'R', 'L', '\n', 0 };
+
+ private static final int FORMAT_VERSION = 1;
+
+ private static final int SECTION_CERTIFICATES = 1;
+
+ private static final int SECTION_KEY = 2;
+
+ private static final int SECTION_SHA1 = 3;
+
+ private static final int SECTION_SIGNATURE = 4; // Skipped
+
+ private static final int SECTION_SHA256 = 5;
+
+ private static final int SECTION_EXTENSION = 255; // Skipped
+
+ // Certificates
+
+ private static final int CERT_SERIAL_LIST = 0x20;
+
+ private static final int CERT_SERIAL_RANGES = 0x21;
+
+ private static final int CERT_SERIAL_BITS = 0x22;
+
+ private static final int CERT_KEY_IDS = 0x23;
+
+ private static final int CERT_EXTENSIONS = 0x39; // Skipped
+
+ private final Map<Blob, CertificateRevocation> certificates = new HashMap<>();
+
+ private static class CertificateRevocation {
+
+ final SerialRangeSet ranges = new SerialRangeSet();
+
+ final Set<String> keyIds = new HashSet<>();
+ }
+
+ // Plain keys
+
+ /**
+ * A byte array that can be used as a key in a {@link Map} or {@link Set}.
+ * {@link #equals(Object)} and {@link #hashCode()} are based on the content.
+ *
+ * @param blob
+ * the array to wrap
+ */
+ @SuppressWarnings("ArrayRecordComponent")
+ private static record Blob(byte[] blob) {
+
+ @Override
+ public final boolean equals(Object any) {
+ if (this == any) {
+ return true;
+ }
+ if (any == null || !(any instanceof Blob)) {
+ return false;
+ }
+ Blob other = (Blob) any;
+ return Arrays.equals(blob, other.blob);
+ }
+
+ @Override
+ public final int hashCode() {
+ return Arrays.hashCode(blob);
+ }
+ }
+
+ private final Set<Blob> blobs = new HashSet<>();
+
+ private final Set<Blob> sha1 = new HashSet<>();
+
+ private final Set<Blob> sha256 = new HashSet<>();
+
+ private OpenSshBinaryKrl() {
+ // No public instantiation, use load(InputStream, boolean) instead.
+ }
+
+ /**
+ * Tells whether the given key has been revoked.
+ *
+ * @param key
+ * {@link PublicKey} to check
+ * @return {@code true} if the key was revoked, {@code false} otherwise
+ */
+ boolean isRevoked(PublicKey key) {
+ if (key instanceof OpenSshCertificate certificate) {
+ if (certificates.isEmpty()) {
+ return false;
+ }
+ // These apply to all certificates
+ if (isRevoked(certificate, certificates.get(null))) {
+ return true;
+ }
+ if (isRevoked(certificate,
+ certificates.get(blob(certificate.getCaPubKey())))) {
+ return true;
+ }
+ // Keys themselves are checked in OpenSshKrl.
+ return false;
+ }
+ if (!blobs.isEmpty() && blobs.contains(blob(key))) {
+ return true;
+ }
+ if (!sha256.isEmpty() && sha256.contains(hash("SHA256", key))) { //$NON-NLS-1$
+ return true;
+ }
+ if (!sha1.isEmpty() && sha1.contains(hash("SHA1", key))) { //$NON-NLS-1$
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isRevoked(OpenSshCertificate certificate,
+ CertificateRevocation revocations) {
+ if (revocations == null) {
+ return false;
+ }
+ String id = certificate.getId();
+ if (!StringUtils.isEmptyOrNull(id) && revocations.keyIds.contains(id)) {
+ return true;
+ }
+ long serial = certificate.getSerial();
+ if (serial != 0 && revocations.ranges.contains(serial)) {
+ return true;
+ }
+ return false;
+ }
+
+ private Blob blob(PublicKey key) {
+ ByteArrayBuffer buf = new ByteArrayBuffer();
+ buf.putRawPublicKey(key);
+ return new Blob(buf.getCompactData());
+ }
+
+ private Blob hash(String algorithm, PublicKey key) {
+ ByteArrayBuffer buf = new ByteArrayBuffer();
+ buf.putRawPublicKey(key);
+ try {
+ return new Blob(MessageDigest.getInstance(algorithm)
+ .digest(buf.getCompactData()));
+ } catch (NoSuchAlgorithmException e) {
+ throw new JGitInternalException(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Loads a binary KRL from the given stream.
+ *
+ * @param in
+ * {@link InputStream} to read from
+ * @param magicSkipped
+ * whether the {@link #MAGIC} bytes at the beginning have already
+ * been skipped
+ * @return a new {@link OpenSshBinaryKrl}.
+ * @throws IOException
+ * if the stream cannot be read as an OpenSSH binary KRL
+ */
+ @NonNull
+ static OpenSshBinaryKrl load(InputStream in, boolean magicSkipped)
+ throws IOException {
+ if (!magicSkipped) {
+ byte[] magic = new byte[MAGIC.length];
+ IO.readFully(in, magic);
+ if (!Arrays.equals(magic, MAGIC)) {
+ throw new StreamCorruptedException(
+ SshdText.get().signKrlInvalidMagic);
+ }
+ }
+ skipHeader(in);
+ return load(in);
+ }
+
+ private static long getUInt(InputStream in) throws IOException {
+ byte[] buf = new byte[Integer.BYTES];
+ IO.readFully(in, buf);
+ return BufferUtils.getUInt(buf);
+ }
+
+ private static long getLong(InputStream in) throws IOException {
+ byte[] buf = new byte[Long.BYTES];
+ IO.readFully(in, buf);
+ return BufferUtils.getLong(buf, 0, Long.BYTES);
+ }
+
+ private static void skipHeader(InputStream in) throws IOException {
+ long version = getUInt(in);
+ if (version != FORMAT_VERSION) {
+ throw new StreamCorruptedException(
+ MessageFormat.format(SshdText.get().signKrlInvalidVersion,
+ Long.valueOf(version)));
+ }
+ // krl_version, generated_date, flags (none defined in version 1)
+ in.skip(24);
+ in.skip(getUInt(in)); // reserved
+ in.skip(getUInt(in)); // comment
+ }
+
+ private static OpenSshBinaryKrl load(InputStream in) throws IOException {
+ OpenSshBinaryKrl krl = new OpenSshBinaryKrl();
+ for (;;) {
+ int sectionType = in.read();
+ if (sectionType < 0) {
+ break; // EOF
+ }
+ switch (sectionType) {
+ case SECTION_CERTIFICATES:
+ readCertificates(krl.certificates, in, getUInt(in));
+ break;
+ case SECTION_KEY:
+ readBlobs("explicit_keys", krl.blobs, in, getUInt(in), 0); //$NON-NLS-1$
+ break;
+ case SECTION_SHA1:
+ readBlobs("fingerprint_sha1", krl.sha1, in, getUInt(in), 20); //$NON-NLS-1$
+ break;
+ case SECTION_SIGNATURE:
+ // Unsupported as of OpenSSH 9.4. It even refuses to load such
+ // KRLs. Just skip it.
+ in.skip(getUInt(in));
+ break;
+ case SECTION_SHA256:
+ readBlobs("fingerprint_sha256", krl.sha256, in, getUInt(in), //$NON-NLS-1$
+ 32);
+ break;
+ case SECTION_EXTENSION:
+ // No extensions are defined for version 1 KRLs.
+ in.skip(getUInt(in));
+ break;
+ default:
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlUnknownSection,
+ Integer.valueOf(sectionType)));
+ }
+ }
+ return krl;
+ }
+
+ private static void readBlobs(String sectionName, Set<Blob> blobs,
+ InputStream in, long sectionLength, long expectedBlobLength)
+ throws IOException {
+ while (sectionLength >= Integer.BYTES) {
+ // Read blobs.
+ long blobLength = getUInt(in);
+ sectionLength -= Integer.BYTES;
+ if (blobLength > sectionLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlBlobLengthInvalid, sectionName,
+ Long.valueOf(blobLength)));
+ }
+ if (expectedBlobLength != 0 && blobLength != expectedBlobLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlBlobLengthInvalidExpected,
+ sectionName, Long.valueOf(blobLength),
+ Long.valueOf(expectedBlobLength)));
+ }
+ byte[] blob = new byte[(int) blobLength];
+ IO.readFully(in, blob);
+ sectionLength -= blobLength;
+ blobs.add(new Blob(blob));
+ }
+ if (sectionLength != 0) {
+ throw new StreamCorruptedException(
+ MessageFormat.format(SshdText.get().signKrlBlobLeftover,
+ sectionName, Long.valueOf(sectionLength)));
+ }
+ }
+
+ private static void readCertificates(Map<Blob, CertificateRevocation> certs,
+ InputStream in, long sectionLength) throws IOException {
+ long keyLength = getUInt(in);
+ sectionLength -= Integer.BYTES;
+ if (keyLength > sectionLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCaKeyLengthInvalid,
+ Long.valueOf(keyLength)));
+ }
+ Blob key = null;
+ if (keyLength > 0) {
+ byte[] blob = new byte[(int) keyLength];
+ IO.readFully(in, blob);
+ key = new Blob(blob);
+ sectionLength -= keyLength;
+ }
+ CertificateRevocation rev = certs.computeIfAbsent(key,
+ k -> new CertificateRevocation());
+ long reservedLength = getUInt(in);
+ sectionLength -= Integer.BYTES;
+ if (reservedLength > sectionLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCaKeyLengthInvalid,
+ Long.valueOf(reservedLength)));
+ }
+ in.skip(reservedLength);
+ sectionLength -= reservedLength;
+ if (sectionLength == 0) {
+ throw new StreamCorruptedException(
+ SshdText.get().signKrlNoCertificateSubsection);
+ }
+ while (sectionLength > 0) {
+ int subSection = in.read();
+ if (subSection < 0) {
+ throw new EOFException();
+ }
+ sectionLength--;
+ if (sectionLength < Integer.BYTES) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCertificateLeftover,
+ Long.valueOf(sectionLength)));
+ }
+ long subLength = getUInt(in);
+ sectionLength -= Integer.BYTES;
+ if (subLength > sectionLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCertificateSubsectionLength,
+ Long.valueOf(subLength)));
+ }
+ if (subLength > 0) {
+ switch (subSection) {
+ case CERT_SERIAL_LIST:
+ readSerials(rev.ranges, in, subLength, false);
+ break;
+ case CERT_SERIAL_RANGES:
+ readSerials(rev.ranges, in, subLength, true);
+ break;
+ case CERT_SERIAL_BITS:
+ readSerialBitSet(rev.ranges, in, subLength);
+ break;
+ case CERT_KEY_IDS:
+ readIds(rev.keyIds, in, subLength);
+ break;
+ case CERT_EXTENSIONS:
+ in.skip(subLength);
+ break;
+ default:
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlUnknownSubsection,
+ Long.valueOf(subSection)));
+ }
+ }
+ sectionLength -= subLength;
+ }
+ }
+
+ private static void readSerials(SerialRangeSet set, InputStream in,
+ long length, boolean ranges) throws IOException {
+ while (length >= Long.BYTES) {
+ long a = getLong(in);
+ length -= Long.BYTES;
+ if (a == 0) {
+ throw new StreamCorruptedException(
+ SshdText.get().signKrlSerialZero);
+ }
+ if (!ranges) {
+ set.add(a);
+ continue;
+ }
+ if (length < Long.BYTES) {
+ throw new StreamCorruptedException(
+ MessageFormat.format(SshdText.get().signKrlShortRange,
+ Long.valueOf(length)));
+ }
+ long b = getLong(in);
+ length -= Long.BYTES;
+ if (Long.compareUnsigned(a, b) > 0) {
+ throw new StreamCorruptedException(
+ SshdText.get().signKrlEmptyRange);
+ }
+ set.add(a, b);
+ }
+ if (length != 0) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCertificateSubsectionLeftover,
+ Long.valueOf(length)));
+ }
+ }
+
+ private static void readSerialBitSet(SerialRangeSet set, InputStream in,
+ long subLength) throws IOException {
+ while (subLength > 0) {
+ if (subLength < Long.BYTES) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCertificateSubsectionLeftover,
+ Long.valueOf(subLength)));
+ }
+ long base = getLong(in);
+ subLength -= Long.BYTES;
+ if (subLength < Integer.BYTES) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCertificateSubsectionLeftover,
+ Long.valueOf(subLength)));
+ }
+ long setLength = getUInt(in);
+ subLength -= Integer.BYTES;
+ if (setLength == 0 || setLength > subLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlInvalidBitSetLength,
+ Long.valueOf(setLength)));
+ }
+ // Now process the bits. Note that the mpint is stored MSB first.
+ //
+ // We set individual serial numbers (one for each set bit) and let
+ // the SerialRangeSet take care of coalescing for successive runs
+ // of set bits.
+ int n = (int) setLength;
+ for (int i = n - 1; i >= 0; i--) {
+ int b = in.read();
+ if (b < 0) {
+ throw new EOFException();
+ } else if (b == 0) {
+ // Stored as an mpint: may have leading zero bytes (actually
+ // at most one; if the high bit of the first byte is set).
+ continue;
+ }
+ for (int bit = 0,
+ mask = 1; bit < Byte.SIZE; bit++, mask <<= 1) {
+ if ((b & mask) != 0) {
+ set.add(base + (i * Byte.SIZE) + bit);
+ }
+ }
+ }
+ subLength -= setLength;
+ }
+ }
+
+ private static void readIds(Set<String> ids, InputStream in, long subLength)
+ throws IOException {
+ while (subLength >= Integer.BYTES) {
+ long length = getUInt(in);
+ subLength -= Integer.BYTES;
+ if (length > subLength) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlInvalidKeyIdLength,
+ Long.valueOf(length)));
+ }
+ byte[] bytes = new byte[(int) length];
+ IO.readFully(in, bytes);
+ ids.add(new String(bytes, StandardCharsets.UTF_8));
+ subLength -= length;
+ }
+ if (subLength != 0) {
+ throw new StreamCorruptedException(MessageFormat.format(
+ SshdText.get().signKrlCertificateSubsectionLeftover,
+ Long.valueOf(subLength)));
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java
new file mode 100644
index 0000000000..7993def90c
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshKrl.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.util.io.ModifiableFileWatcher;
+import org.eclipse.jgit.util.IO;
+
+/**
+ * An implementation of an OpenSSH key revocation list (KRL), either a binary
+ * KRL or a simple list of public keys.
+ */
+class OpenSshKrl extends ModifiableFileWatcher {
+
+ private static record State(Set<String> keys, OpenSshBinaryKrl krl) {
+ // Empty
+ }
+
+ private State state;
+
+ public OpenSshKrl(Path path) {
+ super(path);
+ state = new State(Set.of(), null);
+ }
+
+ public boolean isRevoked(PublicKey key) throws IOException {
+ State current = refresh();
+ return isRevoked(current, key);
+ }
+
+ private boolean isRevoked(State current, PublicKey key) {
+ if (key instanceof OpenSshCertificate cert) {
+ OpenSshBinaryKrl krl = current.krl();
+ if (krl != null && krl.isRevoked(cert)) {
+ return true;
+ }
+ if (isRevoked(current, cert.getCaPubKey())
+ || isRevoked(current, cert.getCertPubKey())) {
+ return true;
+ }
+ return false;
+ }
+ OpenSshBinaryKrl krl = current.krl();
+ if (krl != null) {
+ return krl.isRevoked(key);
+ }
+ return current.keys().contains(PublicKeyEntry.toString(key));
+ }
+
+ private synchronized State refresh() throws IOException {
+ if (checkReloadRequired()) {
+ updateReloadAttributes();
+ try {
+ state = reload(getPath());
+ } catch (NoSuchFileException e) {
+ // File disappeared
+ resetReloadAttributes();
+ state = new State(Set.of(), null);
+ }
+ }
+ return state;
+ }
+
+ private static State reload(Path path) throws IOException {
+ try (BufferedInputStream in = new BufferedInputStream(
+ Files.newInputStream(path))) {
+ byte[] magic = new byte[OpenSshBinaryKrl.MAGIC.length];
+ in.mark(magic.length);
+ IO.readFully(in, magic);
+ if (Arrays.equals(magic, OpenSshBinaryKrl.MAGIC)) {
+ return new State(null, OpenSshBinaryKrl.load(in, true));
+ }
+ // Otherwise try reading it textually
+ in.reset();
+ return loadTextKrl(in);
+ }
+ }
+
+ private static State loadTextKrl(InputStream in) throws IOException {
+ Set<String> keys = new HashSet<>();
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ String line;
+ for (;;) {
+ line = r.readLine();
+ if (line == null) {
+ break;
+ }
+ line = line.strip();
+ if (line.isEmpty() || line.charAt(0) == '#') {
+ continue;
+ }
+ keys.add(AllowedSigners.parsePublicKey(line, 0));
+ }
+ }
+ return new State(keys, null);
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java
new file mode 100644
index 0000000000..aa26886839
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/OpenSshSigningKeyDatabase.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.lib.GpgConfig;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.signing.ssh.CachingSigningKeyDatabase;
+import org.eclipse.jgit.signing.ssh.VerificationException;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.StringUtils;
+
+/**
+ * A {@link CachingSigningKeyDatabase} using the OpenSSH allowed signers file
+ * and the OpenSSH key revocation list.
+ */
+public class OpenSshSigningKeyDatabase implements CachingSigningKeyDatabase {
+
+ // Keep caches of allowed signers and KRLs. Cache by canonical path.
+
+ private static final int DEFAULT_CACHE_SIZE = 5;
+
+ private AtomicInteger cacheSize = new AtomicInteger(DEFAULT_CACHE_SIZE);
+
+ private class LRU<K, V> extends LinkedHashMap<K, V> {
+
+ private static final long serialVersionUID = 1L;
+
+ LRU() {
+ super(DEFAULT_CACHE_SIZE, 0.75f, true);
+ }
+
+ @Override
+ protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
+ return size() > cacheSize.get();
+ }
+ }
+
+ private final HashMap<Path, AllowedSigners> allowedSigners = new LRU<>();
+
+ private final HashMap<Path, OpenSshKrl> revocations = new LRU<>();
+
+ @Override
+ public boolean isRevoked(Repository repository, GpgConfig config,
+ PublicKey key) throws IOException {
+ String fileName = config.getSshRevocationFile();
+ if (StringUtils.isEmptyOrNull(fileName)) {
+ return false;
+ }
+ File file = getFile(repository, fileName);
+ OpenSshKrl revocationList;
+ synchronized (revocations) {
+ revocationList = revocations.computeIfAbsent(file.toPath(),
+ OpenSshKrl::new);
+ }
+ return revocationList.isRevoked(key);
+ }
+
+ @Override
+ public String isAllowed(Repository repository, GpgConfig config,
+ PublicKey key, String namespace, PersonIdent ident)
+ throws IOException, VerificationException {
+ String fileName = config.getSshAllowedSignersFile();
+ if (StringUtils.isEmptyOrNull(fileName)) {
+ // No file configured. Git would error out.
+ return null;
+ }
+ File file = getFile(repository, fileName);
+ AllowedSigners allowed;
+ synchronized (allowedSigners) {
+ allowed = allowedSigners.computeIfAbsent(file.toPath(),
+ AllowedSigners::new);
+ }
+ Instant gitTime = null;
+ if (ident != null) {
+ gitTime = ident.getWhenAsInstant();
+ }
+ return allowed.isAllowed(key, namespace, null, gitTime);
+ }
+
+ private File getFile(@NonNull Repository repository, String fileName)
+ throws IOException {
+ File file;
+ if (fileName.startsWith("~/") //$NON-NLS-1$
+ || fileName.startsWith('~' + File.separator)) {
+ file = FS.DETECTED.resolve(FS.DETECTED.userHome(),
+ fileName.substring(2));
+ } else {
+ file = new File(fileName);
+ if (!file.isAbsolute()) {
+ file = new File(repository.getWorkTree(), fileName);
+ }
+ }
+ return file.getCanonicalFile();
+ }
+
+ @Override
+ public int getCacheSize() {
+ return cacheSize.get();
+ }
+
+ @Override
+ public void setCacheSize(int size) {
+ if (size > 0) {
+ cacheSize.set(size);
+ pruneCache(size);
+ }
+ }
+
+ private void pruneCache(int size) {
+ prune(allowedSigners, size);
+ prune(revocations, size);
+ }
+
+ private void prune(HashMap<?, ?> map, int size) {
+ synchronized (map) {
+ if (map.size() <= size) {
+ return;
+ }
+ Iterator<?> iter = map.entrySet().iterator();
+ int i = 0;
+ while (iter.hasNext() && i < size) {
+ iter.next();
+ i++;
+ }
+ while (iter.hasNext()) {
+ iter.next();
+ iter.remove();
+ }
+ }
+ }
+
+ @Override
+ public void clearCache() {
+ synchronized (allowedSigners) {
+ allowedSigners.clear();
+ }
+ synchronized (revocations) {
+ revocations.clear();
+ }
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java
new file mode 100644
index 0000000000..f4eb884239
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SerialRangeSet.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.util.TreeMap;
+
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+
+/**
+ * Encapsulates the storage for revoked certificate serial numbers.
+ */
+class SerialRangeSet {
+
+ /**
+ * A range of certificate serial numbers [from..to], i.e., with both range
+ * limits included.
+ */
+ private interface SerialRange {
+
+ long from();
+
+ long to();
+ }
+
+ private static record Singleton(long from) implements SerialRange {
+
+ @Override
+ public long to() {
+ return from;
+ }
+ }
+
+ private static record Range(long from, long to) implements SerialRange {
+
+ public Range(long from, long to) {
+ if (Long.compareUnsigned(from, to) > 0) {
+ throw new IllegalArgumentException(
+ SshdText.get().signKrlEmptyRange);
+ }
+ this.from = from;
+ this.to = to;
+ }
+ }
+
+ // We use the same data structure as OpenSSH; basically a TreeSet of mutable
+ // SerialRanges. To get "mutability", the set is implemented as a TreeMap
+ // with the same elements as keys and values.
+ //
+ // get(x) will return null if none of the serial numbers in the range x is
+ // in the set, and some range (partially) overlapping with x otherwise.
+ //
+ // containsKey(x) will return true if there is any (partially) overlapping
+ // range in the TreeMap.
+ private final TreeMap<SerialRange, SerialRange> ranges = new TreeMap<>(
+ SerialRangeSet::compare);
+
+ private static int compare(SerialRange a, SerialRange b) {
+ // Return == if they overlap
+ if (Long.compareUnsigned(a.to(), b.from()) >= 0
+ && Long.compareUnsigned(a.from(), b.to()) <= 0) {
+ return 0;
+ }
+ return Long.compareUnsigned(a.from(), b.from());
+ }
+
+ void add(long serial) {
+ add(ranges, new Singleton(serial));
+ }
+
+ void add(long from, long to) {
+ add(ranges, new Range(from, to));
+ }
+
+ boolean contains(long serial) {
+ return ranges.containsKey(new Singleton(serial));
+ }
+
+ int size() {
+ return ranges.size();
+ }
+
+ boolean isEmpty() {
+ return ranges.isEmpty();
+ }
+
+ private static void add(TreeMap<SerialRange, SerialRange> ranges,
+ SerialRange newRange) {
+ for (;;) {
+ SerialRange existing = ranges.get(newRange);
+ if (existing == null) {
+ break;
+ }
+ if (Long.compareUnsigned(existing.from(), newRange.from()) <= 0
+ && Long.compareUnsigned(existing.to(),
+ newRange.to()) >= 0) {
+ // newRange completely contained in existing
+ return;
+ }
+ ranges.remove(existing);
+ long newFrom = newRange.from();
+ if (Long.compareUnsigned(existing.from(), newFrom) < 0) {
+ newFrom = existing.from();
+ }
+ long newTo = newRange.to();
+ if (Long.compareUnsigned(existing.to(), newTo) > 0) {
+ newTo = existing.to();
+ }
+ newRange = new Range(newFrom, newTo);
+ }
+ // No overlapping range exists: check for coalescing with the
+ // previous/next range
+ SerialRange prev = ranges.floorKey(newRange);
+ if (prev != null && newRange.from() - prev.to() == 1) {
+ ranges.remove(prev);
+ newRange = new Range(prev.from(), newRange.to());
+ }
+ SerialRange next = ranges.ceilingKey(newRange);
+ if (next != null && next.from() - newRange.to() == 1) {
+ ranges.remove(next);
+ newRange = new Range(newRange.from(), next.to());
+ }
+ ranges.put(newRange, newRange);
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java
new file mode 100644
index 0000000000..e2e1a36840
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SigningDatabase.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import org.eclipse.jgit.signing.ssh.CachingSigningKeyDatabase;
+import org.eclipse.jgit.signing.ssh.SigningKeyDatabase;
+
+/**
+ * A global {@link SigningKeyDatabase} instance.
+ */
+public final class SigningDatabase {
+
+ private static SigningKeyDatabase INSTANCE = new OpenSshSigningKeyDatabase();
+
+ private SigningDatabase() {
+ // No instantiation
+ }
+
+ /**
+ * Obtains the current instance.
+ *
+ * @return the global {@link SigningKeyDatabase}
+ */
+ public static synchronized SigningKeyDatabase getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Sets the global {@link SigningKeyDatabase}.
+ *
+ * @param database
+ * to set; if {@code null} a default database using the OpenSSH
+ * allowed signers file and the OpenSSH revocation list mechanism
+ * is used.
+ * @return the previously set {@link SigningKeyDatabase}
+ */
+ public static synchronized SigningKeyDatabase setInstance(
+ SigningKeyDatabase database) {
+ SigningKeyDatabase previous = INSTANCE;
+ if (database != INSTANCE) {
+ if (INSTANCE instanceof CachingSigningKeyDatabase caching) {
+ caching.clearCache();
+ }
+ if (database == null) {
+ INSTANCE = new OpenSshSigningKeyDatabase();
+ } else {
+ INSTANCE = database;
+ }
+ }
+ return previous;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java
new file mode 100644
index 0000000000..040c6d4368
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshCertificateUtils.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.security.PublicKey;
+import java.text.MessageFormat;
+import java.time.Instant;
+
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.signature.BuiltinSignatures;
+import org.apache.sshd.common.signature.Signature;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility methods for working with OpenSSH certificates.
+ */
+final class SshCertificateUtils {
+
+ private static final Logger LOG = LoggerFactory
+ .getLogger(SshCertificateUtils.class);
+
+ /**
+ * Verifies a certificate: checks that it is a user certificate and has a
+ * valid signature, and if a time is given, that the certificate is valid at
+ * that time.
+ *
+ * @param certificate
+ * {@link OpenSshCertificate} to verify
+ * @param signatureTime
+ * {@link Instant} to check whether the certificate is valid at
+ * that time; maybe {@code null}, in which case the valid-time
+ * check is skipped.
+ * @return {@code null} if the certificate is valid; otherwise a descriptive
+ * message
+ */
+ static String verify(OpenSshCertificate certificate,
+ Instant signatureTime) {
+ if (!OpenSshCertificate.Type.USER.equals(certificate.getType())) {
+ return MessageFormat.format(SshdText.get().signNotUserCertificate,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()));
+ }
+ String message = verifySignature(certificate);
+ if (message == null && signatureTime != null) {
+ message = checkExpiration(certificate, signatureTime);
+ }
+ return message;
+ }
+
+ /**
+ * Verifies the signature on a certificate.
+ *
+ * @param certificate
+ * {@link OpenSshCertificate} to verify
+ * @return {@code null} if the signature is valid; otherwise a descriptive
+ * message
+ */
+ static String verifySignature(OpenSshCertificate certificate) {
+ // Verify the signature on the certificate.
+ //
+ // Note that OpenSSH certificates do not support chaining.
+ //
+ // ssh-keygen refuses to create a certificate for a certificate, so the
+ // certified key cannot be another OpenSshCertificate. Additionally,
+ // when creating a certificate ssh-keygen loads the CA private key to
+ // make the signature and reconstructs the public key that it stores in
+ // the certificate from that, so the CA public key also cannot be an
+ // OpenSshCertificate.
+ PublicKey caKey = certificate.getCaPubKey();
+ PublicKey certifiedKey = certificate.getCertPubKey();
+ if (caKey == null
+ || caKey instanceof OpenSshCertificate
+ || certifiedKey == null
+ || certifiedKey instanceof OpenSshCertificate) {
+ return SshdText.get().signCertificateInvalid;
+ }
+ // Verify that key type and algorithm match
+ String keyType = KeyUtils.getKeyType(caKey);
+ String certAlgorithm = certificate.getSignatureAlgorithm();
+ if (!KeyUtils.getCanonicalKeyType(keyType)
+ .equals(KeyUtils.getCanonicalKeyType(certAlgorithm))) {
+ return MessageFormat.format(
+ SshdText.get().signCertAlgorithmMismatch, keyType,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()),
+ certAlgorithm);
+ }
+ BuiltinSignatures factory = BuiltinSignatures
+ .fromFactoryName(certAlgorithm);
+ if (factory == null || !factory.isSupported()) {
+ return MessageFormat.format(SshdText.get().signCertAlgorithmUnknown,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()),
+ certAlgorithm);
+ }
+ Signature signer = factory.create();
+ try {
+ signer.initVerifier(null, caKey);
+ signer.update(null, getBlob(certificate));
+ if (signer.verify(null, certificate.getRawSignature())) {
+ return null;
+ }
+ } catch (Exception e) {
+ LOG.warn("{}", SshdText.get().signLogFailure, e); //$NON-NLS-1$
+ return SshdText.get().signSeeLog;
+ }
+ return MessageFormat.format(SshdText.get().signCertificateInvalid,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()));
+ }
+
+ private static byte[] getBlob(OpenSshCertificate certificate) {
+ // Theoretically, this should be just certificate.getMessage(). But
+ // Apache MINA sshd has a bug and may return additional bytes if the
+ // certificate is not the first thing in the buffer it was read from.
+ // As a work-around, re-create the signed blob from scratch.
+ //
+ // This may be replaced by return certificate.getMessage() once the
+ // upstream bug is fixed.
+ //
+ // See https://github.com/apache/mina-sshd/issues/618
+ Buffer tmp = new ByteArrayBuffer();
+ tmp.putString(certificate.getKeyType());
+ tmp.putBytes(certificate.getNonce());
+ tmp.putRawPublicKeyBytes(certificate.getCertPubKey());
+ tmp.putLong(certificate.getSerial());
+ tmp.putInt(certificate.getType().getCode());
+ tmp.putString(certificate.getId());
+ Buffer list = new ByteArrayBuffer();
+ list.putStringList(certificate.getPrincipals(), false);
+ tmp.putBytes(list.getCompactData());
+ tmp.putLong(certificate.getValidAfter());
+ tmp.putLong(certificate.getValidBefore());
+ tmp.putCertificateOptions(certificate.getCriticalOptions());
+ tmp.putCertificateOptions(certificate.getExtensions());
+ tmp.putString(certificate.getReserved());
+ Buffer inner = new ByteArrayBuffer();
+ inner.putRawPublicKey(certificate.getCaPubKey());
+ tmp.putBytes(inner.getCompactData());
+ return tmp.getCompactData();
+ }
+
+ /**
+ * Checks whether a certificate is valid at a given time.
+ *
+ * @param certificate
+ * {@link OpenSshCertificate} to check
+ * @param signatureTime
+ * {@link Instant} to check
+ * @return {@code null} if the certificate is valid at the given instant;
+ * otherwise a descriptive message
+ */
+ static String checkExpiration(OpenSshCertificate certificate,
+ @NonNull Instant signatureTime) {
+ long instant = signatureTime.getEpochSecond();
+ if (Long.compareUnsigned(instant, certificate.getValidAfter()) < 0) {
+ return MessageFormat.format(SshdText.get().signCertificateTooEarly,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()));
+ } else if (Long.compareUnsigned(instant,
+ certificate.getValidBefore()) > 0) {
+ return MessageFormat.format(SshdText.get().signCertificateExpired,
+ KeyUtils.getFingerPrint(certificate.getCaPubKey()));
+ }
+ return null;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java
new file mode 100644
index 0000000000..bc72196a22
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureConstants.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.nio.charset.StandardCharsets;
+
+import org.eclipse.jgit.lib.Constants;
+
+/**
+ * Defines common constants for SSH signatures.
+ */
+final class SshSignatureConstants {
+
+ private static final String SIGNATURE_END = "-----END SSH SIGNATURE-----"; //$NON-NLS-1$
+
+ static final byte[] MAGIC = { 'S', 'S', 'H', 'S', 'I', 'G' };
+
+ static final int VERSION = 1;
+
+ static final String NAMESPACE = "git"; //$NON-NLS-1$
+
+ static final byte[] ARMOR_HEAD = Constants.SSH_SIGNATURE_PREFIX
+ .getBytes(StandardCharsets.US_ASCII);
+
+ static final byte[] ARMOR_END = SIGNATURE_END
+ .getBytes(StandardCharsets.US_ASCII);
+
+ private SshSignatureConstants() {
+ // No instantiation
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java
new file mode 100644
index 0000000000..76be340bc7
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSignatureVerifier.java
@@ -0,0 +1,319 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.text.MessageFormat;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Locale;
+
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.signature.BuiltinSignatures;
+import org.apache.sshd.common.signature.Signature;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.lib.GpgConfig;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.SignatureVerifier;
+import org.eclipse.jgit.signing.ssh.CachingSigningKeyDatabase;
+import org.eclipse.jgit.signing.ssh.SigningKeyDatabase;
+import org.eclipse.jgit.signing.ssh.VerificationException;
+import org.eclipse.jgit.util.Base64;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.eclipse.jgit.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A {@link SignatureVerifier} for SSH signatures.
+ */
+public class SshSignatureVerifier implements SignatureVerifier {
+
+ private static final Logger LOG = LoggerFactory
+ .getLogger(SshSignatureVerifier.class);
+
+ private static final byte[] OBJECT = { 'o', 'b', 'j', 'e', 'c', 't', ' ' };
+
+ private static final byte[] TREE = { 't', 'r', 'e', 'e', ' ' };
+
+ private static final byte[] TYPE = { 't', 'y', 'p', 'e', ' ' };
+
+ @Override
+ public String getName() {
+ return "ssh"; //$NON-NLS-1$
+ }
+
+ @Override
+ public SignatureVerification verify(Repository repository, GpgConfig config,
+ byte[] data, byte[] signatureData) throws IOException {
+ // This is a bit stupid. SSH signatures do not store a signer, nor a
+ // time the signature was created. So we must use the committer's or
+ // tagger's PersonIdent, but here we have neither. But... if we see
+ // that the data is a commit or tag, then we can parse the PersonIdent
+ // from the data.
+ //
+ // Note: we cannot assume that absent a principal recorded in the
+ // allowedSignersFile or on a certificate that the key used to sign the
+ // commit belonged to the committer.
+ PersonIdent gitIdentity = getGitIdentity(data);
+ Date signatureDate = null;
+ Instant signatureInstant = null;
+ if (gitIdentity != null) {
+ signatureDate = gitIdentity.getWhen();
+ signatureInstant = gitIdentity.getWhenAsInstant();
+ }
+
+ TrustLevel trust = TrustLevel.NEVER;
+ byte[] decodedSignature;
+ try {
+ decodedSignature = dearmor(signatureData);
+ } catch (IllegalArgumentException e) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ null, null, false, false, trust,
+ MessageFormat.format(SshdText.get().signInvalidSignature,
+ e.getLocalizedMessage()));
+ }
+ int start = RawParseUtils.match(decodedSignature, 0,
+ SshSignatureConstants.MAGIC);
+ if (start < 0) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ null, null, false, false, trust,
+ SshdText.get().signInvalidMagic);
+ }
+ ByteArrayBuffer signature = new ByteArrayBuffer(decodedSignature, start,
+ decodedSignature.length - start);
+
+ long version = signature.getUInt();
+ if (version != SshSignatureConstants.VERSION) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ null, null, false, false, trust,
+ MessageFormat.format(SshdText.get().signInvalidVersion,
+ Long.toString(version)));
+ }
+
+ PublicKey key = signature.getPublicKey();
+ String fingerprint;
+ if (key instanceof OpenSshCertificate cert) {
+ fingerprint = KeyUtils.getFingerPrint(cert.getCertPubKey());
+ String message = SshCertificateUtils.verify(cert, signatureInstant);
+ if (message != null) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust, message);
+ }
+ } else {
+ fingerprint = KeyUtils.getFingerPrint(key);
+ }
+
+ String namespace = signature.getString();
+ if (!SshSignatureConstants.NAMESPACE.equals(namespace)) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust,
+ MessageFormat.format(SshdText.get().signInvalidNamespace,
+ namespace));
+ }
+
+ signature.getString(); // Skip the reserved field
+ String hashAlgorithm = signature.getString();
+ byte[] hash;
+ try {
+ hash = MessageDigest
+ .getInstance(hashAlgorithm.toUpperCase(Locale.ROOT))
+ .digest(data);
+ } catch (NoSuchAlgorithmException e) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust,
+ MessageFormat.format(
+ SshdText.get().signUnknownHashAlgorithm,
+ hashAlgorithm));
+ }
+ ByteArrayBuffer rawSignature = new ByteArrayBuffer(
+ signature.getBytes());
+ if (signature.available() > 0) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust,
+ SshdText.get().signGarbageAtEnd);
+ }
+
+ String signatureAlgorithm = rawSignature.getString();
+ switch (signatureAlgorithm) {
+ case KeyPairProvider.SSH_DSS:
+ case KeyPairProvider.SSH_DSS_CERT:
+ case KeyPairProvider.SSH_RSA:
+ case KeyPairProvider.SSH_RSA_CERT:
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust,
+ MessageFormat.format(SshdText.get().signInvalidAlgorithm,
+ signatureAlgorithm));
+ }
+
+ String keyType = KeyUtils
+ .getSignatureAlgorithm(KeyUtils.getKeyType(key), key);
+ if (!KeyUtils.getCanonicalKeyType(keyType)
+ .equals(KeyUtils.getCanonicalKeyType(signatureAlgorithm))) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust,
+ MessageFormat.format(
+ SshdText.get().signMismatchedSignatureAlgorithm,
+ keyType, signatureAlgorithm));
+ }
+
+ BuiltinSignatures factory = BuiltinSignatures
+ .fromFactoryName(signatureAlgorithm);
+ if (factory == null || !factory.isSupported()) {
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, null, false, false, trust,
+ MessageFormat.format(
+ SshdText.get().signUnknownSignatureAlgorithm,
+ signatureAlgorithm));
+ }
+
+ boolean valid;
+ String message = null;
+ try {
+ Signature verifier = factory.create();
+ verifier.initVerifier(null,
+ key instanceof OpenSshCertificate cert
+ ? cert.getCertPubKey()
+ : key);
+ // Feed it the data
+ Buffer toSign = new ByteArrayBuffer();
+ toSign.putRawBytes(SshSignatureConstants.MAGIC);
+ toSign.putString(SshSignatureConstants.NAMESPACE);
+ toSign.putUInt(0); // reserved: zero-length string
+ toSign.putString(hashAlgorithm);
+ toSign.putBytes(hash);
+ verifier.update(null, toSign.getCompactData());
+ valid = verifier.verify(null, rawSignature.getBytes());
+ } catch (Exception e) {
+ LOG.warn("{}", SshdText.get().signLogFailure, e); //$NON-NLS-1$
+ valid = false;
+ message = SshdText.get().signSeeLog;
+ }
+ boolean expired = false;
+ String principal = null;
+ if (valid) {
+ if (rawSignature.available() > 0) {
+ valid = false;
+ message = SshdText.get().signGarbageAtEnd;
+ } else {
+ SigningKeyDatabase database = SigningKeyDatabase.getInstance();
+ if (database.isRevoked(repository, config, key)) {
+ valid = false;
+ if (key instanceof OpenSshCertificate certificate) {
+ message = MessageFormat.format(
+ SshdText.get().signCertificateRevoked,
+ KeyUtils.getFingerPrint(
+ certificate.getCaPubKey()));
+ } else {
+ message = SshdText.get().signKeyRevoked;
+ }
+ } else {
+ // This may turn a positive verification into a failed one.
+ try {
+ principal = database.isAllowed(repository, config, key,
+ SshSignatureConstants.NAMESPACE, gitIdentity);
+ if (!StringUtils.isEmptyOrNull(principal)) {
+ trust = TrustLevel.FULL;
+ } else {
+ valid = false;
+ message = SshdText.get().signNoPrincipalMatched;
+ trust = TrustLevel.UNKNOWN;
+ }
+ } catch (VerificationException e) {
+ valid = false;
+ message = e.getMessage();
+ expired = e.isExpired();
+ } catch (IOException e) {
+ LOG.warn("{}", SshdText.get().signLogFailure, e); //$NON-NLS-1$
+ valid = false;
+ message = SshdText.get().signSeeLog;
+ }
+ }
+ }
+ }
+ return new SignatureVerification(getName(), signatureDate, null,
+ fingerprint, principal, valid, expired, trust, message);
+ }
+
+ private static PersonIdent getGitIdentity(byte[] rawObject) {
+ // Data from a commit will start with "tree ID\n".
+ int i = RawParseUtils.match(rawObject, 0, TREE);
+ if (i > 0) {
+ i = RawParseUtils.committer(rawObject, 0);
+ if (i < 0) {
+ return null;
+ }
+ return RawParseUtils.parsePersonIdent(rawObject, i);
+ }
+ // Data from a tag will start with "object ID\ntype ".
+ i = RawParseUtils.match(rawObject, 0, OBJECT);
+ if (i > 0) {
+ i = RawParseUtils.nextLF(rawObject, i);
+ i = RawParseUtils.match(rawObject, i, TYPE);
+ if (i > 0) {
+ i = RawParseUtils.tagger(rawObject, 0);
+ if (i < 0) {
+ return null;
+ }
+ return RawParseUtils.parsePersonIdent(rawObject, i);
+ }
+ }
+ return null;
+ }
+
+ private static byte[] dearmor(byte[] data) {
+ int start = RawParseUtils.match(data, 0,
+ SshSignatureConstants.ARMOR_HEAD);
+ if (start > 0) {
+ if (data[start] == '\r') {
+ start++;
+ }
+ if (data[start] == '\n') {
+ start++;
+ }
+ }
+ int end = data.length;
+ if (end > start + 1 && data[end - 1] == '\n') {
+ end--;
+ if (end > start + 1 && data[end - 1] == '\r') {
+ end--;
+ }
+ }
+ end = end - SshSignatureConstants.ARMOR_END.length;
+ if (end >= 0 && end >= start
+ && RawParseUtils.match(data, end,
+ SshSignatureConstants.ARMOR_END) >= 0) {
+ // end is fine: on the first the character of the end marker
+ } else {
+ // No end marker.
+ end = data.length;
+ }
+ if (start < 0) {
+ start = 0;
+ }
+ return Base64.decode(data, start, end - start);
+ }
+
+ @Override
+ public void clear() {
+ SigningKeyDatabase database = SigningKeyDatabase.getInstance();
+ if (database instanceof CachingSigningKeyDatabase caching) {
+ caching.clearCache();
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java
new file mode 100644
index 0000000000..8cfe5f4766
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/signing/ssh/SshSigner.java
@@ -0,0 +1,485 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.StreamCorruptedException;
+import java.io.StringReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.text.MessageFormat;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.apache.sshd.client.auth.pubkey.PublicKeyIdentity;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.config.keys.loader.KeyPairResourceParser;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.session.SessionContext;
+import org.apache.sshd.common.signature.BuiltinSignatures;
+import org.apache.sshd.common.signature.Signature;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.apache.sshd.common.util.security.SecurityUtils;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.api.errors.CanceledException;
+import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException;
+import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
+import org.eclipse.jgit.internal.transport.sshd.PasswordProviderWrapper;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.internal.transport.sshd.agent.SshAgentClient;
+import org.eclipse.jgit.lib.GpgConfig;
+import org.eclipse.jgit.lib.GpgSignature;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.Signer;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.sshd.KeyPasswordProviderFactory;
+import org.eclipse.jgit.transport.sshd.agent.Connector;
+import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory;
+import org.eclipse.jgit.util.Base64;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.FS.ExecutionResult;
+import org.eclipse.jgit.util.StringUtils;
+import org.eclipse.jgit.util.SystemReader;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A {@link Signer} to create SSH signatures.
+ *
+ * @see <a href=
+ * "https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig">PROTOCOL.sshsig</a>
+ */
+public class SshSigner implements Signer {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SshSigner.class);
+
+ private static final String GIT_KEY_PREFIX = "key::"; //$NON-NLS-1$
+
+ // Base64 encoded lines should not be longer than 75 characters, plus the
+ // newline.
+ private static final int LINE_LENGTH = 75;
+
+ @Override
+ public GpgSignature sign(Repository repository, GpgConfig config,
+ byte[] data, PersonIdent committer, String signingKey,
+ CredentialsProvider credentialsProvider) throws CanceledException,
+ IOException, UnsupportedSigningFormatException {
+ byte[] hash;
+ try {
+ hash = MessageDigest.getInstance("SHA512").digest(data); //$NON-NLS-1$
+ } catch (NoSuchAlgorithmException e) {
+ throw new UnsupportedSigningFormatException(
+ MessageFormat.format(
+ SshdText.get().signUnknownHashAlgorithm, "SHA512"), //$NON-NLS-1$
+ e);
+ }
+ Buffer toSign = new ByteArrayBuffer();
+ toSign.putRawBytes(SshSignatureConstants.MAGIC);
+ toSign.putString(SshSignatureConstants.NAMESPACE);
+ toSign.putUInt(0); // reserved: zero-length string
+ toSign.putString("sha512"); //$NON-NLS-1$
+ toSign.putBytes(hash);
+ String key = signingKey;
+ if (StringUtils.isEmptyOrNull(key)) {
+ key = config.getSigningKey();
+ }
+ if (StringUtils.isEmptyOrNull(key)) {
+ key = defaultKeyCommand(repository, config);
+ // According to documentation, this is supposed to return a
+ // valid SSH public key prefixed with "key::". We don't enforce
+ // this: there might be older command implementations (like just
+ // calling "ssh-add -L") that return keys without prefix.
+ }
+ PublicKeyIdentity identity;
+ try {
+ identity = getIdentity(key, committer, credentialsProvider);
+ } catch (GeneralSecurityException e) {
+ throw new UnsupportedSigningFormatException(MessageFormat
+ .format(SshdText.get().signPublicKeyError, key), e);
+ }
+ String algorithm = KeyUtils
+ .getKeyType(identity.getKeyIdentity().getPublic());
+ switch (algorithm) {
+ case KeyPairProvider.SSH_DSS:
+ case KeyPairProvider.SSH_DSS_CERT:
+ throw new UnsupportedSigningFormatException(
+ SshdText.get().signInvalidKeyDSA);
+ case KeyPairProvider.SSH_RSA:
+ algorithm = KeyUtils.RSA_SHA512_KEY_TYPE_ALIAS;
+ break;
+ case KeyPairProvider.SSH_RSA_CERT:
+ algorithm = KeyUtils.RSA_SHA512_CERT_TYPE_ALIAS;
+ break;
+ default:
+ break;
+ }
+
+ Map.Entry<String, byte[]> rawSignature;
+ try {
+ rawSignature = identity.sign(null, algorithm,
+ toSign.getCompactData());
+ } catch (Exception e) {
+ throw new UnsupportedSigningFormatException(
+ SshdText.get().signSignatureError, e);
+ }
+ algorithm = rawSignature.getKey();
+ Buffer signature = new ByteArrayBuffer();
+ signature.putRawBytes(SshSignatureConstants.MAGIC);
+ signature.putUInt(SshSignatureConstants.VERSION);
+ signature.putPublicKey(identity.getKeyIdentity().getPublic());
+ signature.putString(SshSignatureConstants.NAMESPACE);
+ signature.putUInt(0); // reserved: zero-length string
+ signature.putString("sha512"); //$NON-NLS-1$
+ Buffer sig = new ByteArrayBuffer();
+ sig.putString(KeyUtils.getSignatureAlgorithm(algorithm,
+ identity.getKeyIdentity().getPublic()));
+ sig.putBytes(rawSignature.getValue());
+ signature.putBytes(sig.getCompactData());
+ return armor(signature.getCompactData());
+ }
+
+ private static String defaultKeyCommand(@NonNull Repository repository,
+ @NonNull GpgConfig config) throws IOException {
+ String command = config.getSshDefaultKeyCommand();
+ if (StringUtils.isEmptyOrNull(command)) {
+ return null;
+ }
+ FS fileSystem = repository.getFS();
+ if (fileSystem == null) {
+ fileSystem = FS.DETECTED;
+ }
+ ProcessBuilder builder = fileSystem.runInShell(command,
+ new String[] {});
+ ExecutionResult result = null;
+ try {
+ result = fileSystem.execute(builder, null);
+ int exitCode = result.getRc();
+ if (exitCode == 0) {
+ // The command is supposed to return a public key in its first
+ // line on stdout.
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(
+ result.getStdout().openInputStream(),
+ SystemReader.getInstance()
+ .getDefaultCharset()))) {
+ String line = r.readLine();
+ if (line != null) {
+ line = line.strip();
+ }
+ if (StringUtils.isEmptyOrNull(line)) {
+ throw new IOException(MessageFormat.format(
+ SshdText.get().signDefaultKeyEmpty, command));
+ }
+ return line;
+ }
+ }
+ TemporaryBuffer stderr = result.getStderr();
+ throw new IOException(MessageFormat.format(
+ SshdText.get().signDefaultKeyFailed, command,
+ Integer.toString(exitCode), toString(stderr)));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(
+ MessageFormat.format(
+ SshdText.get().signDefaultKeyInterrupted, command),
+ e);
+ } finally {
+ if (result != null) {
+ if (result.getStderr() != null) {
+ result.getStderr().destroy();
+ }
+ if (result.getStdout() != null) {
+ result.getStdout().destroy();
+ }
+ }
+ }
+ }
+
+ private static String toString(TemporaryBuffer b) {
+ if (b != null) {
+ try {
+ return new String(b.toByteArray(4000),
+ SystemReader.getInstance().getDefaultCharset());
+ } catch (IOException e) {
+ LOG.warn("{}", SshdText.get().signStderr, e); //$NON-NLS-1$
+ }
+ }
+ return ""; //$NON-NLS-1$
+ }
+
+ private static PublicKeyIdentity getIdentity(String signingKey,
+ PersonIdent committer, CredentialsProvider credentials)
+ throws CanceledException, GeneralSecurityException, IOException {
+ if (StringUtils.isEmptyOrNull(signingKey)) {
+ throw new IllegalArgumentException(SshdText.get().signNoSigningKey);
+ }
+ PublicKey publicKey = null;
+ PrivateKey privateKey = null;
+ File keyFile = null;
+ if (signingKey.startsWith(GIT_KEY_PREFIX)) {
+ try (StringReader r = new StringReader(
+ signingKey.substring(GIT_KEY_PREFIX.length()))) {
+ publicKey = fromEntry(
+ AuthorizedKeyEntry.readAuthorizedKeys(r, true));
+ }
+ } else if (signingKey.startsWith("~/") //$NON-NLS-1$
+ || signingKey.startsWith('~' + File.separator)) {
+ keyFile = new File(FS.DETECTED.userHome(), signingKey.substring(2));
+ } else {
+ try (StringReader r = new StringReader(signingKey)) {
+ publicKey = fromEntry(
+ AuthorizedKeyEntry.readAuthorizedKeys(r, true));
+ } catch (IOException e) {
+ // Ignore and try to read as a file
+ keyFile = new File(signingKey);
+ }
+ }
+ if (keyFile != null && keyFile.isFile()) {
+ try {
+ publicKey = fromEntry(AuthorizedKeyEntry
+ .readAuthorizedKeys(keyFile.toPath()));
+ if (publicKey == null) {
+ throw new IOException(MessageFormat.format(
+ SshdText.get().signTooManyPublicKeys, keyFile));
+ }
+ // Try to find the private key so we don't go looking for
+ // the agent (or PKCS#11) in vain.
+ keyFile = getPrivateKeyFile(keyFile.getParentFile(),
+ keyFile.getName());
+ if (keyFile != null) {
+ try {
+ KeyPair pair = loadPrivateKey(keyFile.toPath(),
+ credentials);
+ if (pair != null) {
+ PublicKey pk = pair.getPublic();
+ if (pk == null) {
+ privateKey = pair.getPrivate();
+ } else {
+ PublicKey original = publicKey;
+ if (publicKey instanceof OpenSshCertificate cert) {
+ original = cert.getCertPubKey();
+ }
+ if (KeyUtils.compareKeys(original, pk)) {
+ privateKey = pair.getPrivate();
+ }
+ }
+ }
+ } catch (IOException e) {
+ // Apparently it wasn't a private key file. Ignore.
+ }
+ }
+ } catch (StreamCorruptedException e) {
+ // File is readable, but apparently not a public key. Try to
+ // load it as a private key.
+ KeyPair pair = loadPrivateKey(keyFile.toPath(), credentials);
+ if (pair != null) {
+ publicKey = pair.getPublic();
+ privateKey = pair.getPrivate();
+ }
+ }
+ }
+ if (publicKey == null) {
+ throw new IOException(MessageFormat
+ .format(SshdText.get().signNoPublicKey, signingKey));
+ }
+ if (publicKey instanceof OpenSshCertificate cert) {
+ String message = SshCertificateUtils.verify(cert,
+ committer.getWhenAsInstant());
+ if (message != null) {
+ throw new IOException(message);
+ }
+ }
+ if (privateKey == null) {
+ // Could be in the agent, or a PKCS#11 key. The normal procedure
+ // with PKCS#11 keys is to put them in the agent and let the agent
+ // deal with it.
+ //
+ // This may or may not work well. For instance, the agent might ask
+ // for a passphrase for PKCS#11 keys... also, the OpenSSH ssh-agent
+ // had a bug with signing using PKCS#11 certificates in the agent;
+ // see https://bugzilla.mindrot.org/show_bug.cgi?id=3613 . If there
+ // are troubles, we might do the PKCS#11 dance ourselves, but we'd
+ // need additional configuration for the PKCS#11 library. (Plus
+ // some refactoring in the Pkcs11Provider.)
+ return new AgentIdentity(publicKey);
+
+ }
+ return new KeyPairIdentity(new KeyPair(publicKey, privateKey));
+ }
+
+ private static File getPrivateKeyFile(File directory,
+ String publicKeyName) {
+ if (publicKeyName.endsWith(".pub")) { //$NON-NLS-1$
+ String privateKeyName = publicKeyName.substring(0,
+ publicKeyName.length() - 4);
+ if (!privateKeyName.isEmpty()) {
+ File keyFile = new File(directory, privateKeyName);
+ if (keyFile.isFile()) {
+ return keyFile;
+ }
+ if (privateKeyName.endsWith("-cert")) { //$NON-NLS-1$
+ privateKeyName = privateKeyName.substring(0,
+ privateKeyName.length() - 5);
+ if (!privateKeyName.isEmpty()) {
+ keyFile = new File(directory, privateKeyName);
+ if (keyFile.isFile()) {
+ return keyFile;
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ private static KeyPair loadPrivateKey(Path path,
+ CredentialsProvider credentials)
+ throws CanceledException, GeneralSecurityException, IOException {
+ if (!Files.isRegularFile(path)) {
+ return null;
+ }
+ KeyPairResourceParser parser = SecurityUtils.getKeyPairResourceParser();
+ if (parser != null) {
+ PasswordProviderWrapper provider = null;
+ if (credentials != null) {
+ provider = new PasswordProviderWrapper(
+ () -> KeyPasswordProviderFactory.getInstance()
+ .apply(credentials));
+ }
+ try {
+ Collection<KeyPair> keyPairs = parser.loadKeyPairs(null, path,
+ provider);
+ if (keyPairs.size() != 1) {
+ throw new GeneralSecurityException(MessageFormat.format(
+ SshdText.get().signTooManyPrivateKeys, path));
+ }
+ return keyPairs.iterator().next();
+ } catch (AuthenticationCanceledException e) {
+ throw new CanceledException(e.getMessage());
+ }
+ }
+ return null;
+ }
+
+ private static GpgSignature armor(byte[] data) throws IOException {
+ try (ByteArrayOutputStream b = new ByteArrayOutputStream()) {
+ b.write(SshSignatureConstants.ARMOR_HEAD);
+ b.write('\n');
+ String encoded = Base64.encodeBytes(data);
+ int length = encoded.length();
+ int column = 0;
+ for (int i = 0; i < length; i++) {
+ b.write(encoded.charAt(i));
+ column++;
+ if (column == LINE_LENGTH) {
+ b.write('\n');
+ column = 0;
+ }
+ }
+ if (column > 0) {
+ b.write('\n');
+ }
+ b.write(SshSignatureConstants.ARMOR_END);
+ b.write('\n');
+ return new GpgSignature(b.toByteArray());
+ }
+ }
+
+ private static PublicKey fromEntry(List<AuthorizedKeyEntry> entries)
+ throws GeneralSecurityException, IOException {
+ if (entries == null || entries.size() != 1) {
+ return null;
+ }
+ return entries.get(0).resolvePublicKey(null,
+ PublicKeyEntryResolver.FAILING);
+ }
+
+ @Override
+ public boolean canLocateSigningKey(Repository repository, GpgConfig config,
+ PersonIdent committer, String signingKey,
+ CredentialsProvider credentialsProvider) throws CanceledException {
+ String key = signingKey;
+ if (key == null) {
+ key = config.getSigningKey();
+ }
+ return !(StringUtils.isEmptyOrNull(key)
+ && StringUtils.isEmptyOrNull(config.getSshDefaultKeyCommand()));
+ }
+
+ private static class KeyPairIdentity implements PublicKeyIdentity {
+
+ private final @NonNull KeyPair pair;
+
+ KeyPairIdentity(@NonNull KeyPair pair) {
+ this.pair = pair;
+ }
+
+ @Override
+ public KeyPair getKeyIdentity() {
+ return pair;
+ }
+
+ @Override
+ public Entry<String, byte[]> sign(SessionContext session, String algo,
+ byte[] data) throws Exception {
+ BuiltinSignatures factory = BuiltinSignatures.fromFactoryName(algo);
+ if (factory == null || !factory.isSupported()) {
+ throw new GeneralSecurityException(MessageFormat.format(
+ SshdText.get().signUnknownSignatureAlgorithm, algo));
+ }
+ Signature signer = factory.create();
+ signer.initSigner(null, pair.getPrivate());
+ signer.update(null, data);
+ return new SimpleImmutableEntry<>(factory.getName(),
+ signer.sign(null));
+ }
+ }
+
+ private static class AgentIdentity extends KeyPairIdentity {
+
+ AgentIdentity(PublicKey publicKey) {
+ super(new KeyPair(publicKey, null));
+ }
+
+ @Override
+ public Entry<String, byte[]> sign(SessionContext session, String algo,
+ byte[] data) throws Exception {
+ ConnectorFactory factory = ConnectorFactory.getDefault();
+ Connector connector = factory == null ? null
+ : factory.create("", null); //$NON-NLS-1$
+ if (connector == null) {
+ throw new IOException(SshdText.get().signNoAgent);
+ }
+ try (SshAgentClient agent = new SshAgentClient(connector)) {
+ return agent.sign(null, getKeyIdentity().getPublic(), algo,
+ data);
+ }
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationCanceledException.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationCanceledException.java
new file mode 100644
index 0000000000..aa4623571d
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationCanceledException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2020, 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;
+
+import java.util.concurrent.CancellationException;
+
+/**
+ * An exception to report that the user canceled the SSH authentication.
+ */
+public class AuthenticationCanceledException extends CancellationException {
+
+ // If this is not a CancellationException sshd will try other authentication
+ // mechanisms.
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a new {@link AuthenticationCanceledException}.
+ */
+ public AuthenticationCanceledException() {
+ super(SshdText.get().authenticationCanceled);
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java
new file mode 100644
index 0000000000..add79b35c9
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright (C) 2022 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;
+
+import static org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider.getKeyId;
+
+import java.security.KeyPair;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.sshd.client.auth.password.PasswordAuthenticationReporter;
+import org.apache.sshd.client.auth.password.UserAuthPassword;
+import org.apache.sshd.client.auth.pubkey.PublicKeyAuthenticationReporter;
+import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.config.keys.KeyUtils;
+
+/**
+ * Provides a log of authentication attempts for a {@link ClientSession}.
+ */
+public class AuthenticationLogger {
+
+ private final List<String> messages = new ArrayList<>();
+
+ // We're interested in this log only in the failure case, so we don't need
+ // to log authentication success.
+
+ private final PublicKeyAuthenticationReporter pubkeyLogger = new PublicKeyAuthenticationReporter() {
+
+ private boolean hasAttempts;
+
+ @Override
+ public void signalAuthenticationAttempt(ClientSession session,
+ String service, KeyPair identity, String signature)
+ throws Exception {
+ hasAttempts = true;
+ String message;
+ if (identity.getPrivate() == null) {
+ // SSH agent key
+ message = MessageFormat.format(
+ SshdText.get().authPubkeyAttemptAgent,
+ UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
+ getKeyId(session, identity), signature);
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authPubkeyAttempt,
+ UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
+ getKeyId(session, identity), signature);
+ }
+ messages.add(message);
+ }
+
+ @Override
+ public void signalAuthenticationExhausted(ClientSession session,
+ String service) throws Exception {
+ String message;
+ if (hasAttempts) {
+ message = MessageFormat.format(
+ SshdText.get().authPubkeyExhausted,
+ UserAuthPublicKey.NAME);
+ } else {
+ message = MessageFormat.format(SshdText.get().authPubkeyNoKeys,
+ UserAuthPublicKey.NAME);
+ }
+ messages.add(message);
+ hasAttempts = false;
+ }
+
+ @Override
+ public void signalAuthenticationFailure(ClientSession session,
+ String service, KeyPair identity, boolean partial,
+ List<String> serverMethods) throws Exception {
+ String message;
+ if (partial) {
+ message = MessageFormat.format(
+ SshdText.get().authPubkeyPartialSuccess,
+ UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
+ getKeyId(session, identity), serverMethods);
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authPubkeyFailure,
+ UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
+ getKeyId(session, identity));
+ }
+ messages.add(message);
+ }
+ };
+
+ private final PasswordAuthenticationReporter passwordLogger = new PasswordAuthenticationReporter() {
+
+ private int attempts;
+
+ @Override
+ public void signalAuthenticationAttempt(ClientSession session,
+ String service, String oldPassword, boolean modified,
+ String newPassword) throws Exception {
+ attempts++;
+ String message;
+ if (modified) {
+ message = MessageFormat.format(
+ SshdText.get().authPasswordChangeAttempt,
+ UserAuthPassword.NAME, Integer.valueOf(attempts));
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authPasswordAttempt,
+ UserAuthPassword.NAME, Integer.valueOf(attempts));
+ }
+ messages.add(message);
+ }
+
+ @Override
+ public void signalAuthenticationExhausted(ClientSession session,
+ String service) throws Exception {
+ String message;
+ if (attempts > 0) {
+ message = MessageFormat.format(
+ SshdText.get().authPasswordExhausted,
+ UserAuthPassword.NAME);
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authPasswordNotTried,
+ UserAuthPassword.NAME);
+ }
+ messages.add(message);
+ attempts = 0;
+ }
+
+ @Override
+ public void signalAuthenticationFailure(ClientSession session,
+ String service, String password, boolean partial,
+ List<String> serverMethods) throws Exception {
+ String message;
+ if (partial) {
+ message = MessageFormat.format(
+ SshdText.get().authPasswordPartialSuccess,
+ UserAuthPassword.NAME, serverMethods);
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authPasswordFailure,
+ UserAuthPassword.NAME);
+ }
+ messages.add(message);
+ }
+ };
+
+ private final GssApiWithMicAuthenticationReporter gssLogger = new GssApiWithMicAuthenticationReporter() {
+
+ private boolean hasAttempts;
+
+ @Override
+ public void signalAuthenticationAttempt(ClientSession session,
+ String service, String mechanism) {
+ hasAttempts = true;
+ String message = MessageFormat.format(
+ SshdText.get().authGssApiAttempt,
+ GssApiWithMicAuthFactory.NAME, mechanism);
+ messages.add(message);
+ }
+
+ @Override
+ public void signalAuthenticationExhausted(ClientSession session,
+ String service) {
+ String message;
+ if (hasAttempts) {
+ message = MessageFormat.format(
+ SshdText.get().authGssApiExhausted,
+ GssApiWithMicAuthFactory.NAME);
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authGssApiNotTried,
+ GssApiWithMicAuthFactory.NAME);
+ }
+ messages.add(message);
+ hasAttempts = false;
+ }
+
+ @Override
+ public void signalAuthenticationFailure(ClientSession session,
+ String service, String mechanism, boolean partial,
+ List<String> serverMethods) {
+ String message;
+ if (partial) {
+ message = MessageFormat.format(
+ SshdText.get().authGssApiPartialSuccess,
+ GssApiWithMicAuthFactory.NAME, mechanism,
+ serverMethods);
+ } else {
+ message = MessageFormat.format(
+ SshdText.get().authGssApiFailure,
+ GssApiWithMicAuthFactory.NAME, mechanism);
+ }
+ messages.add(message);
+ }
+ };
+
+ /**
+ * Creates a new {@link AuthenticationLogger} and configures the
+ * {@link ClientSession} to report authentication attempts through this
+ * instance.
+ *
+ * @param session
+ * to configure
+ */
+ public AuthenticationLogger(ClientSession session) {
+ session.setPublicKeyAuthenticationReporter(pubkeyLogger);
+ session.setPasswordAuthenticationReporter(passwordLogger);
+ session.setAttribute(
+ GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER,
+ gssLogger);
+ // TODO: keyboard-interactive? sshd 2.8.0 has no callback
+ // interface for it.
+ }
+
+ /**
+ * Retrieves the log messages for the authentication attempts.
+ *
+ * @return the messages as an unmodifiable list
+ */
+ public List<String> getLog() {
+ return Collections.unmodifiableList(messages);
+ }
+
+ /**
+ * Drops all previously recorded log messages.
+ */
+ public void clear() {
+ messages.clear();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java
new file mode 100644
index 0000000000..cbd6a64140
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2018, 2022 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;
+
+import static java.text.MessageFormat.format;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.concurrent.CancellationException;
+
+import javax.security.auth.DestroyFailedException;
+
+import org.apache.sshd.common.AttributeRepository.AttributeKey;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
+import org.apache.sshd.common.session.SessionContext;
+import org.apache.sshd.common.util.io.resource.IoResource;
+import org.apache.sshd.common.util.security.SecurityUtils;
+import org.eclipse.jgit.transport.sshd.KeyCache;
+
+/**
+ * A {@link FileKeyPairProvider} that uses an external {@link KeyCache}.
+ */
+public class CachingKeyPairProvider extends FileKeyPairProvider
+ implements Iterable<KeyPair> {
+
+ /**
+ * An attribute set on the {@link SessionContext} recording loaded keys by
+ * fingerprint. This enables us to provide nicer output by showing key
+ * paths, if possible. Users can identify key identities used easier by
+ * filename than by fingerprint.
+ */
+ public static final AttributeKey<Map<String, Path>> KEY_PATHS_BY_FINGERPRINT = new AttributeKey<>();
+
+ private final KeyCache cache;
+
+ /**
+ * Creates a new {@link CachingKeyPairProvider} using the given
+ * {@link KeyCache}. If the cache is {@code null}, this is a simple
+ * {@link FileKeyPairProvider}.
+ *
+ * @param paths
+ * to load keys from
+ * @param cache
+ * to use, may be {@code null} if no external caching is desired
+ */
+ public CachingKeyPairProvider(List<Path> paths, KeyCache cache) {
+ super(paths);
+ this.cache = cache;
+ }
+
+ @Override
+ public Iterator<KeyPair> iterator() {
+ return iterator(null);
+ }
+
+ private Iterator<KeyPair> iterator(SessionContext session) {
+ Collection<? extends Path> resources = getPaths();
+ if (resources.isEmpty()) {
+ return Collections.emptyListIterator();
+ }
+ return new CancellingKeyPairIterator(session, resources);
+ }
+
+ @Override
+ public Iterable<KeyPair> loadKeys(SessionContext session) {
+ return () -> iterator(session);
+ }
+
+ static String getKeyId(ClientSession session, KeyPair identity) {
+ String fingerprint = KeyUtils.getFingerPrint(identity.getPublic());
+ Map<String, Path> registered = session
+ .getAttribute(KEY_PATHS_BY_FINGERPRINT);
+ if (registered != null) {
+ Path path = registered.get(fingerprint);
+ if (path != null) {
+ Path home = session
+ .resolveAttribute(JGitSshClient.HOME_DIRECTORY);
+ if (home != null && path.startsWith(home)) {
+ try {
+ path = home.relativize(path);
+ String pathString = path.toString();
+ if (!pathString.isEmpty()) {
+ return "~" + File.separator + pathString; //$NON-NLS-1$
+ }
+ } catch (IllegalArgumentException e) {
+ // Cannot be relativized. Ignore, and work with the
+ // original path
+ }
+ }
+ return path.toString();
+ }
+ }
+ return fingerprint;
+ }
+
+ private KeyPair loadKey(SessionContext session, Path path)
+ throws IOException, GeneralSecurityException {
+ if (!Files.exists(path)) {
+ log.warn(format(SshdText.get().identityFileNotFound, path));
+ return null;
+ }
+ IoResource<Path> resource = getIoResource(session, path);
+ if (cache == null) {
+ return loadKey(session, resource, path, getPasswordFinder());
+ }
+ Throwable[] t = { null };
+ KeyPair key = cache.get(path, p -> {
+ try {
+ return loadKey(session, resource, p, getPasswordFinder());
+ } catch (IOException | GeneralSecurityException e) {
+ t[0] = e;
+ return null;
+ }
+ });
+ if (t[0] != null) {
+ if (t[0] instanceof CancellationException) {
+ throw (CancellationException) t[0];
+ }
+ throw new IOException(
+ format(SshdText.get().keyLoadFailed, resource), t[0]);
+ }
+ return key;
+ }
+
+ private KeyPair loadKey(SessionContext session, NamedResource resource,
+ Path path, FilePasswordProvider passwordProvider)
+ throws IOException, GeneralSecurityException {
+ try (InputStream stream = Files.newInputStream(path)) {
+ Iterable<KeyPair> ids = SecurityUtils.loadKeyPairIdentities(session,
+ resource, stream, passwordProvider);
+ if (ids == null) {
+ throw new InvalidKeyException(
+ format(SshdText.get().identityFileNoKey, path));
+ }
+ Iterator<KeyPair> keys = ids.iterator();
+ if (!keys.hasNext()) {
+ throw new InvalidKeyException(format(
+ SshdText.get().identityFileUnsupportedFormat, path));
+ }
+ KeyPair result = keys.next();
+ PublicKey pk = result.getPublic();
+ if (pk != null) {
+ Map<String, Path> registered = session
+ .getAttribute(KEY_PATHS_BY_FINGERPRINT);
+ if (registered == null) {
+ registered = new HashMap<>();
+ session.setAttribute(KEY_PATHS_BY_FINGERPRINT, registered);
+ }
+ registered.put(KeyUtils.getFingerPrint(pk), path);
+ }
+ if (keys.hasNext()) {
+ log.warn(format(SshdText.get().identityFileMultipleKeys, path));
+ keys.forEachRemaining(k -> {
+ PrivateKey priv = k.getPrivate();
+ if (priv != null) {
+ try {
+ priv.destroy();
+ } catch (DestroyFailedException e) {
+ // Ignore
+ }
+ }
+ });
+ }
+ return result;
+ }
+ }
+
+ private class CancellingKeyPairIterator implements Iterator<KeyPair> {
+
+ private final SessionContext context;
+
+ private final Iterator<Path> paths;
+
+ private KeyPair nextItem;
+
+ private boolean nextSet;
+
+ public CancellingKeyPairIterator(SessionContext session,
+ Collection<? extends Path> resources) {
+ List<Path> copy = new ArrayList<>(resources.size());
+ copy.addAll(resources);
+ paths = copy.iterator();
+ context = session;
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (nextSet) {
+ return nextItem != null;
+ }
+ nextSet = true;
+ while (nextItem == null && paths.hasNext()) {
+ try {
+ nextItem = loadKey(context, paths.next());
+ } catch (CancellationException cancelled) {
+ throw cancelled;
+ } catch (Exception other) {
+ log.warn(other.getMessage(), other);
+ }
+ }
+ return nextItem != null;
+ }
+
+ @Override
+ public KeyPair next() {
+ if (!nextSet && !hasNext()) {
+ throw new NoSuchElementException();
+ }
+ KeyPair result = nextItem;
+ nextItem = null;
+ nextSet = false;
+ if (result == null) {
+ throw new NoSuchElementException();
+ }
+ return result;
+ }
+
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java
new file mode 100644
index 0000000000..323c51d5a5
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiMechanisms.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
+
+/**
+ * Global repository of GSS-API mechanisms that we can use.
+ */
+public class GssApiMechanisms {
+
+ private GssApiMechanisms() {
+ // No instantiation
+ }
+
+ /** Prefix to use with {@link GSSName#NT_HOSTBASED_SERVICE}. */
+ public static final String GSSAPI_HOST_PREFIX = "host@"; //$NON-NLS-1$
+
+ /** The {@link Oid} of Kerberos 5. */
+ public static final Oid KERBEROS_5 = createOid("1.2.840.113554.1.2.2"); //$NON-NLS-1$
+
+ /** SGNEGO is not to be used with ssh. */
+ public static final Oid SPNEGO = createOid("1.3.6.1.5.5.2"); //$NON-NLS-1$
+
+ /** Protects {@link #supportedMechanisms}. */
+ private static final Object LOCK = new Object();
+
+ /**
+ * The {@link AtomicBoolean} is set to {@code true} when the mechanism could
+ * be initialized successfully at least once.
+ */
+ private static Map<Oid, Boolean> supportedMechanisms;
+
+ /**
+ * Retrieves an immutable collection of the supported mechanisms.
+ *
+ * @return the supported mechanisms
+ */
+ @NonNull
+ public static Collection<Oid> getSupportedMechanisms() {
+ synchronized (LOCK) {
+ if (supportedMechanisms == null) {
+ GSSManager manager = GSSManager.getInstance();
+ Oid[] mechs = manager.getMechs();
+ Map<Oid, Boolean> mechanisms = new LinkedHashMap<>();
+ if (mechs != null) {
+ for (Oid oid : mechs) {
+ mechanisms.put(oid, Boolean.FALSE);
+ }
+ }
+ supportedMechanisms = mechanisms;
+ }
+ return Collections.unmodifiableSet(supportedMechanisms.keySet());
+ }
+ }
+
+ /**
+ * Report that this mechanism was used successfully.
+ *
+ * @param mechanism
+ * that worked
+ */
+ public static void worked(@NonNull Oid mechanism) {
+ synchronized (LOCK) {
+ supportedMechanisms.put(mechanism, Boolean.TRUE);
+ }
+ }
+
+ /**
+ * Mark the mechanisms as failed.
+ *
+ * @param mechanism
+ * to mark
+ */
+ public static void failed(@NonNull Oid mechanism) {
+ synchronized (LOCK) {
+ Boolean worked = supportedMechanisms.get(mechanism);
+ if (worked != null && !worked.booleanValue()) {
+ // If it never worked, remove it
+ supportedMechanisms.remove(mechanism);
+ }
+ }
+ }
+
+ /**
+ * Resolves an {@link InetSocketAddress}.
+ *
+ * @param remote
+ * to resolve
+ * @return the resolved {@link InetAddress}, or {@code null} if unresolved.
+ */
+ public static InetAddress resolve(@NonNull InetSocketAddress remote) {
+ InetAddress address = remote.getAddress();
+ if (address == null) {
+ try {
+ address = InetAddress.getByName(remote.getHostString());
+ } catch (UnknownHostException e) {
+ return null;
+ }
+ }
+ return address;
+ }
+
+ /**
+ * Determines a canonical host name for use use with GSS-API.
+ *
+ * @param remote
+ * to get the host name from
+ * @return the canonical host name, if it can be determined, otherwise the
+ * {@link InetSocketAddress#getHostString() unprocessed host name}.
+ */
+ @NonNull
+ public static String getCanonicalName(@NonNull InetSocketAddress remote) {
+ InetAddress address = resolve(remote);
+ if (address == null) {
+ return remote.getHostString();
+ }
+ return address.getCanonicalHostName();
+ }
+
+ /**
+ * Creates a {@link GSSContext} for the given mechanism to authenticate with
+ * the host given by {@code fqdn}.
+ *
+ * @param mechanism
+ * {@link Oid} of the mechanism to use
+ * @param fqdn
+ * fully qualified domain name of the host to authenticate with
+ * @return the context, if the mechanism is available and the context could
+ * be created, or {@code null} otherwise
+ */
+ public static GSSContext createContext(@NonNull Oid mechanism,
+ @NonNull String fqdn) {
+ GSSContext context = null;
+ try {
+ GSSManager manager = GSSManager.getInstance();
+ context = manager.createContext(
+ manager.createName(
+ GssApiMechanisms.GSSAPI_HOST_PREFIX + fqdn,
+ GSSName.NT_HOSTBASED_SERVICE),
+ mechanism, null, GSSContext.DEFAULT_LIFETIME);
+ } catch (GSSException e) {
+ closeContextSilently(context);
+ failed(mechanism);
+ return null;
+ }
+ worked(mechanism);
+ return context;
+ }
+
+ /**
+ * Closes (disposes of) a {@link GSSContext} ignoring any
+ * {@link GSSException}s.
+ *
+ * @param context
+ * to dispose
+ */
+ public static void closeContextSilently(GSSContext context) {
+ if (context != null) {
+ try {
+ context.dispose();
+ } catch (GSSException e) {
+ // Ignore
+ }
+ }
+ }
+
+ private static Oid createOid(String rep) {
+ try {
+ return new Oid(rep);
+ } catch (GSSException e) {
+ // Does not occur
+ return null;
+ }
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthFactory.java
new file mode 100644
index 0000000000..e4b3716fc7
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthFactory.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import java.io.IOException;
+
+import org.apache.sshd.client.auth.AbstractUserAuthFactory;
+import org.apache.sshd.client.auth.UserAuth;
+import org.apache.sshd.client.session.ClientSession;
+
+/**
+ * Factory to create {@link GssApiWithMicAuthentication} handlers.
+ */
+public class GssApiWithMicAuthFactory extends AbstractUserAuthFactory {
+
+ /** The authentication identifier for GSSApi-with-MIC. */
+ public static final String NAME = "gssapi-with-mic"; //$NON-NLS-1$
+
+ /** The singleton {@link GssApiWithMicAuthFactory}. */
+ public static final GssApiWithMicAuthFactory INSTANCE = new GssApiWithMicAuthFactory();
+
+ private GssApiWithMicAuthFactory() {
+ super(NAME);
+ }
+
+ @Override
+ public UserAuth createUserAuth(ClientSession session)
+ throws IOException {
+ return new GssApiWithMicAuthentication();
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java
new file mode 100644
index 0000000000..df01db316b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) 2018, 2022 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;
+
+import static java.text.MessageFormat.format;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.net.UnknownHostException;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.sshd.client.auth.AbstractUserAuth;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.MessageProp;
+import org.ietf.jgss.Oid;
+
+/**
+ * GSSAPI-with-MIC authentication handler (Kerberos 5).
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc4462">RFC 4462</a>
+ */
+public class GssApiWithMicAuthentication extends AbstractUserAuth {
+
+ /** Synonym used in RFC 4462. */
+ private static final byte SSH_MSG_USERAUTH_GSSAPI_RESPONSE = SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST;
+
+ /** Synonym used in RFC 4462. */
+ private static final byte SSH_MSG_USERAUTH_GSSAPI_TOKEN = SshConstants.SSH_MSG_USERAUTH_INFO_RESPONSE;
+
+ private enum ProtocolState {
+ STARTED, TOKENS, MIC_SENT, FAILED
+ }
+
+ private Collection<Oid> mechanisms;
+
+ private Iterator<Oid> nextMechanism;
+
+ private Oid currentMechanism;
+
+ private ProtocolState state;
+
+ private GSSContext context;
+
+ /** Creates a new {@link GssApiWithMicAuthentication}. */
+ public GssApiWithMicAuthentication() {
+ super(GssApiWithMicAuthFactory.NAME);
+ }
+
+ @Override
+ protected boolean sendAuthDataRequest(ClientSession session, String service)
+ throws Exception {
+ if (mechanisms == null) {
+ mechanisms = GssApiMechanisms.getSupportedMechanisms();
+ nextMechanism = mechanisms.iterator();
+ }
+ if (context != null) {
+ close(false);
+ }
+ GssApiWithMicAuthenticationReporter reporter = session.getAttribute(
+ GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER);
+ if (!nextMechanism.hasNext()) {
+ reporter.signalAuthenticationExhausted(session, service);
+ return false;
+ }
+ state = ProtocolState.STARTED;
+ currentMechanism = nextMechanism.next();
+ // RFC 4462 states that SPNEGO must not be used with ssh
+ while (GssApiMechanisms.SPNEGO.equals(currentMechanism)) {
+ if (!nextMechanism.hasNext()) {
+ reporter.signalAuthenticationExhausted(session, service);
+ return false;
+ }
+ currentMechanism = nextMechanism.next();
+ }
+ try {
+ String hostName = getHostName(session);
+ context = GssApiMechanisms.createContext(currentMechanism,
+ hostName);
+ context.requestMutualAuth(true);
+ context.requestConf(true);
+ context.requestInteg(true);
+ context.requestCredDeleg(true);
+ context.requestAnonymity(false);
+ } catch (GSSException | NullPointerException e) {
+ close(true);
+ if (log.isDebugEnabled()) {
+ log.debug(format(SshdText.get().gssapiInitFailure,
+ currentMechanism.toString()));
+ }
+ currentMechanism = null;
+ state = ProtocolState.FAILED;
+ return false;
+ }
+ if (reporter != null) {
+ reporter.signalAuthenticationAttempt(session, service,
+ currentMechanism.toString());
+ }
+ Buffer buffer = session
+ .createBuffer(SshConstants.SSH_MSG_USERAUTH_REQUEST);
+ buffer.putString(session.getUsername());
+ buffer.putString(service);
+ buffer.putString(getName());
+ buffer.putInt(1);
+ buffer.putBytes(currentMechanism.getDER());
+ session.writePacket(buffer);
+ return true;
+ }
+
+ @Override
+ protected boolean processAuthDataRequest(ClientSession session,
+ String service, Buffer in) throws Exception {
+ // SSH_MSG_USERAUTH_FAILURE and SSH_MSG_USERAUTH_SUCCESS, as well as
+ // SSH_MSG_USERAUTH_BANNER are handled by the framework.
+ int command = in.getUByte();
+ if (context == null) {
+ return false;
+ }
+ try {
+ switch (command) {
+ case SSH_MSG_USERAUTH_GSSAPI_RESPONSE: {
+ if (state != ProtocolState.STARTED) {
+ return unexpectedMessage(command);
+ }
+ // Initial reply from the server with the mechanism to use.
+ Oid mechanism = new Oid(in.getBytes());
+ if (!currentMechanism.equals(mechanism)) {
+ return false;
+ }
+ replyToken(session, service, new byte[0]);
+ return true;
+ }
+ case SSH_MSG_USERAUTH_GSSAPI_TOKEN: {
+ if (context.isEstablished() || state != ProtocolState.TOKENS) {
+ return unexpectedMessage(command);
+ }
+ // Server sent us a token
+ replyToken(session, service, in.getBytes());
+ return true;
+ }
+ default:
+ return unexpectedMessage(command);
+ }
+ } catch (GSSException e) {
+ log.warn(format(SshdText.get().gssapiFailure,
+ currentMechanism.toString()), e);
+ state = ProtocolState.FAILED;
+ return false;
+ }
+ }
+
+ @Override
+ public void destroy() {
+ try {
+ close(false);
+ } finally {
+ super.destroy();
+ }
+ }
+
+ private void close(boolean silent) {
+ try {
+ if (context != null) {
+ context.dispose();
+ context = null;
+ }
+ } catch (GSSException e) {
+ if (!silent) {
+ log.warn(SshdText.get().gssapiFailure, e);
+ }
+ }
+ }
+
+ private void sendToken(ClientSession session, byte[] receivedToken)
+ throws IOException, GSSException {
+ state = ProtocolState.TOKENS;
+ byte[] token = context.initSecContext(receivedToken, 0,
+ receivedToken.length);
+ if (token != null) {
+ Buffer buffer = session.createBuffer(SSH_MSG_USERAUTH_GSSAPI_TOKEN);
+ buffer.putBytes(token);
+ session.writePacket(buffer);
+ }
+ }
+
+ private void sendMic(ClientSession session, String service)
+ throws IOException, GSSException {
+ state = ProtocolState.MIC_SENT;
+ // Produce MIC
+ Buffer micBuffer = new ByteArrayBuffer();
+ micBuffer.putBytes(session.getSessionId());
+ micBuffer.putByte(SshConstants.SSH_MSG_USERAUTH_REQUEST);
+ micBuffer.putString(session.getUsername());
+ micBuffer.putString(service);
+ micBuffer.putString(getName());
+ byte[] micBytes = micBuffer.getCompactData();
+ byte[] mic = context.getMIC(micBytes, 0, micBytes.length,
+ new MessageProp(0, true));
+ Buffer buffer = session
+ .createBuffer(SshConstants.SSH_MSG_USERAUTH_GSSAPI_MIC);
+ buffer.putBytes(mic);
+ session.writePacket(buffer);
+ }
+
+ private void replyToken(ClientSession session, String service, byte[] bytes)
+ throws IOException, GSSException {
+ sendToken(session, bytes);
+ if (context.isEstablished()) {
+ sendMic(session, service);
+ }
+ }
+
+ private String getHostName(ClientSession session) {
+ SocketAddress remote = session.getConnectAddress();
+ if (remote instanceof InetSocketAddress) {
+ InetAddress address = GssApiMechanisms
+ .resolve((InetSocketAddress) remote);
+ if (address != null) {
+ return address.getCanonicalHostName();
+ }
+ }
+ if (session instanceof JGitClientSession) {
+ String hostName = ((JGitClientSession) session).getHostConfigEntry()
+ .getHostName();
+ try {
+ hostName = InetAddress.getByName(hostName)
+ .getCanonicalHostName();
+ } catch (UnknownHostException e) {
+ // Ignore here; try with the non-canonical name
+ }
+ return hostName;
+ }
+ throw new IllegalStateException(
+ "Wrong session class :" + session.getClass().getName()); //$NON-NLS-1$
+ }
+
+ private boolean unexpectedMessage(int command) {
+ log.warn(format(SshdText.get().gssapiUnexpectedMessage, getName(),
+ Integer.toString(command)));
+ return false;
+ }
+
+ @Override
+ public void signalAuthMethodSuccess(ClientSession session, String service,
+ Buffer buffer) throws Exception {
+ GssApiWithMicAuthenticationReporter reporter = session.getAttribute(
+ GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER);
+ if (reporter != null) {
+ reporter.signalAuthenticationSuccess(session, service,
+ currentMechanism.toString());
+ }
+ }
+
+ @Override
+ public void signalAuthMethodFailure(ClientSession session, String service,
+ boolean partial, List<String> serverMethods, Buffer buffer)
+ throws Exception {
+ GssApiWithMicAuthenticationReporter reporter = session.getAttribute(
+ GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER);
+ if (reporter != null) {
+ reporter.signalAuthenticationFailure(session, service,
+ currentMechanism.toString(), partial, serverMethods);
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java
new file mode 100644
index 0000000000..201a131650
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2022 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;
+
+import java.util.List;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.AttributeRepository.AttributeKey;
+
+/**
+ * Callback interface for recording authentication state in
+ * {@link GssApiWithMicAuthentication}.
+ */
+public interface GssApiWithMicAuthenticationReporter {
+
+ /**
+ * An {@link AttributeKey} for a {@link ClientSession} holding the
+ * {@link GssApiWithMicAuthenticationReporter}.
+ */
+ static final AttributeKey<GssApiWithMicAuthenticationReporter> GSS_AUTHENTICATION_REPORTER = new AttributeKey<>();
+
+ /**
+ * Called when a new authentication attempt is made.
+ *
+ * @param session
+ * the {@link ClientSession}
+ * @param service
+ * the name of the requesting SSH service name
+ * @param mechanism
+ * the OID of the mechanism used
+ */
+ default void signalAuthenticationAttempt(ClientSession session,
+ String service, String mechanism) {
+ // nothing
+ }
+
+ /**
+ * Called when there are no more mechanisms to try.
+ *
+ * @param session
+ * the {@link ClientSession}
+ * @param service
+ * the name of the requesting SSH service name
+ */
+ default void signalAuthenticationExhausted(ClientSession session,
+ String service) {
+ // nothing
+ }
+
+ /**
+ * Called when authentication was succeessful.
+ *
+ * @param session
+ * the {@link ClientSession}
+ * @param service
+ * the name of the requesting SSH service name
+ * @param mechanism
+ * the OID of the mechanism used
+ */
+ default void signalAuthenticationSuccess(ClientSession session,
+ String service, String mechanism) {
+ // nothing
+ }
+
+ /**
+ * Called when the authentication was not successful.
+ *
+ * @param session
+ * the {@link ClientSession}
+ * @param service
+ * the name of the requesting SSH service name
+ * @param mechanism
+ * the OID of the mechanism used
+ * @param partial
+ * {@code true} if authentication was partially successful,
+ * meaning one continues with additional authentication methods
+ * given by {@code serverMethods}
+ * @param serverMethods
+ * the {@link List} of authentication methods that can continue
+ */
+ default void signalAuthenticationFailure(ClientSession session,
+ String service, String mechanism, boolean partial,
+ List<String> serverMethods) {
+ // nothing
+ }
+}
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
new file mode 100644
index 0000000000..32d6facbc3
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitClientSession.java
@@ -0,0 +1,744 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import static java.text.MessageFormat.format;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_IDENTIFICATION_SIZE;
+
+import java.io.IOException;
+import java.io.StreamCorruptedException;
+import java.net.SocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.apache.sshd.client.ClientBuilder;
+import org.apache.sshd.client.ClientFactoryManager;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.keyverifier.ServerKeyVerifier;
+import org.apache.sshd.client.session.ClientSessionImpl;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.FactoryManager;
+import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.PropertyResolver;
+import org.apache.sshd.common.cipher.BuiltinCiphers;
+import org.apache.sshd.common.cipher.Cipher;
+import org.apache.sshd.common.cipher.CipherFactory;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.io.IoWriteFuture;
+import org.apache.sshd.common.kex.BuiltinDHFactories;
+import org.apache.sshd.common.kex.DHFactory;
+import org.apache.sshd.common.kex.KeyExchangeFactory;
+import org.apache.sshd.common.kex.extension.KexExtensionHandler;
+import org.apache.sshd.common.kex.extension.KexExtensionHandler.AvailabilityPhase;
+import org.apache.sshd.common.kex.extension.KexExtensions;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.signature.BuiltinSignatures;
+import org.apache.sshd.common.util.Readable;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.eclipse.jgit.errors.InvalidPatternException;
+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;
+
+/**
+ * A {@link org.apache.sshd.client.session.ClientSession ClientSession} that can
+ * be associated with the {@link HostConfigEntry} the session was created for.
+ * The {@link JGitSshClient} creates such sessions and sets this association.
+ * <p>
+ * Also provides for associating a JGit {@link CredentialsProvider} with a
+ * session.
+ * </p>
+ */
+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 &lt; 8.0 read; OpenSSH
+ * 8.0 changed it to 8Mb, but that seems excessive for the purpose stated in
+ * RFC 4253. The Apache MINA sshd default in
+ * {@link org.apache.sshd.core.CoreModuleProperties#MAX_IDENTIFICATION_SIZE}
+ * is 16kb.
+ */
+ private static final int DEFAULT_MAX_IDENTIFICATION_SIZE = 64 * 1024;
+
+ /**
+ * Cipher implementations that we never ever want to use, even if Apache
+ * MINA SSHD has implementations for them.
+ */
+ private static final Set<? extends CipherFactory> FORBIDDEN_CIPHERS = EnumSet
+ .of(BuiltinCiphers.none);
+
+ private HostConfigEntry hostConfig;
+
+ private CredentialsProvider credentialsProvider;
+
+ private boolean isInitialKex = true;
+
+ private List<NamedFactory<Cipher>> ciphers;
+
+ private volatile StatefulProxyConnector proxyHandler;
+
+ /**
+ * @param manager
+ * client factory manager
+ * @param session
+ * the session
+ * @throws Exception
+ * an error occurred
+ */
+ public JGitClientSession(ClientFactoryManager manager, IoSession session)
+ throws Exception {
+ super(manager, session);
+ }
+
+ /**
+ * Retrieves the {@link HostConfigEntry} this session was created for.
+ *
+ * @return the {@link HostConfigEntry}, or {@code null} if none set
+ */
+ public HostConfigEntry getHostConfigEntry() {
+ return hostConfig;
+ }
+
+ /**
+ * Sets the {@link HostConfigEntry} this session was created for.
+ *
+ * @param hostConfig
+ * the {@link HostConfigEntry}
+ */
+ public void setHostConfigEntry(HostConfigEntry hostConfig) {
+ this.hostConfig = hostConfig;
+ }
+
+ /**
+ * Sets the {@link CredentialsProvider} for this session.
+ *
+ * @param provider
+ * to set
+ */
+ public void setCredentialsProvider(CredentialsProvider provider) {
+ credentialsProvider = provider;
+ }
+
+ /**
+ * Retrieves the {@link CredentialsProvider} set for this session.
+ *
+ * @return the provider, or {@code null} if none is set.
+ */
+ public CredentialsProvider getCredentialsProvider() {
+ return credentialsProvider;
+ }
+
+ /**
+ * Sets a {@link StatefulProxyConnector} to handle proxy connection
+ * protocols.
+ *
+ * @param handler
+ * to set
+ */
+ public void setProxyHandler(StatefulProxyConnector handler) {
+ proxyHandler = handler;
+ }
+
+ @Override
+ protected IoWriteFuture sendIdentification(String ident,
+ List<String> extraLines) throws Exception {
+ StatefulProxyConnector proxy = proxyHandler;
+ if (proxy != null) {
+ // We must not block here; the framework starts reading messages
+ // from the peer only once the initial sendKexInit() following
+ // this call to sendIdentification() has returned!
+ proxy.runWhenDone(() -> {
+ JGitClientSession.super.sendIdentification(ident, extraLines);
+ return null;
+ });
+ // Called only from the ClientSessionImpl constructor, where the
+ // return value is ignored.
+ return null;
+ }
+ return super.sendIdentification(ident, extraLines);
+ }
+
+ @Override
+ protected byte[] sendKexInit() throws Exception {
+ StatefulProxyConnector proxy = proxyHandler;
+ if (proxy != null) {
+ // We must not block here; the framework starts reading messages
+ // from the peer only once the initial sendKexInit() has
+ // returned!
+ proxy.runWhenDone(() -> {
+ JGitClientSession.super.sendKexInit();
+ return null;
+ });
+ // This is called only from the ClientSessionImpl
+ // constructor, where the return value is ignored.
+ return null;
+ }
+ return super.sendKexInit();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * As long as we're still setting up the proxy connection, diverts messages
+ * to the {@link StatefulProxyConnector}.
+ */
+ @Override
+ public void messageReceived(Readable buffer) throws Exception {
+ StatefulProxyConnector proxy = proxyHandler;
+ if (proxy != null) {
+ proxy.messageReceived(getIoSession(), buffer);
+ } else {
+ super.messageReceived(buffer);
+ }
+ }
+
+ Set<String> getAllAvailableSignatureAlgorithms() {
+ Set<String> allAvailable = new HashSet<>();
+ BuiltinSignatures.VALUES.forEach(s -> allAvailable.add(s.getName()));
+ BuiltinSignatures.getRegisteredExtensions()
+ .forEach(s -> allAvailable.add(s.getName()));
+ return allAvailable;
+ }
+
+ private void setNewFactories(Collection<String> defaultFactories,
+ Collection<String> finalFactories) {
+ // If new factory names were added make sure we actually have factories
+ // for them all.
+ //
+ // But add new ones at the end: we don't want to change the order for
+ // pubkey auth, and any new ones added here were not included in the
+ // default set for some reason, such as being deprecated or weak.
+ //
+ // The order for KEX is determined by the order in the proposal string,
+ // but the order in pubkey auth is determined by the order in the
+ // factory list (possibly overridden via ssh config
+ // PubkeyAcceptedAlgorithms; see JGitPublicKeyAuthentication).
+ Set<String> resultSet = new LinkedHashSet<>(defaultFactories);
+ resultSet.addAll(finalFactories);
+ setSignatureFactoriesNames(resultSet);
+ }
+
+ @Override
+ protected String resolveAvailableSignaturesProposal(
+ FactoryManager manager) {
+ List<String> defaultSignatures = getSignatureFactoriesNames();
+ HostConfigEntry config = resolveAttribute(
+ JGitSshClient.HOST_CONFIG_ENTRY);
+ String algorithms = config
+ .getProperty(SshConstants.HOST_KEY_ALGORITHMS);
+ if (!StringUtils.isEmptyOrNull(algorithms)) {
+ List<String> result = modifyAlgorithmList(defaultSignatures,
+ getAllAvailableSignatureAlgorithms(), algorithms,
+ SshConstants.HOST_KEY_ALGORITHMS);
+ if (!result.isEmpty()) {
+ if (log.isDebugEnabled()) {
+ log.debug(SshConstants.HOST_KEY_ALGORITHMS + ' ' + result);
+ }
+ setNewFactories(defaultSignatures, result);
+ return String.join(",", result); //$NON-NLS-1$
+ }
+ log.warn(format(SshdText.get().configNoKnownAlgorithms,
+ SshConstants.HOST_KEY_ALGORITHMS, algorithms));
+ }
+ // No HostKeyAlgorithms; using default -- change order to put existing
+ // keys first.
+ ServerKeyVerifier verifier = getServerKeyVerifier();
+ if (verifier instanceof ServerKeyLookup) {
+ SocketAddress remoteAddress = resolvePeerAddress(
+ resolveAttribute(JGitSshClient.ORIGINAL_REMOTE_ADDRESS));
+ List<PublicKey> allKnownKeys = ((ServerKeyLookup) verifier)
+ .lookup(this, remoteAddress);
+ Set<String> reordered = new LinkedHashSet<>();
+ for (PublicKey key : allKnownKeys) {
+ if (key != null) {
+ String keyType = KeyUtils.getKeyType(key);
+ if (keyType != null) {
+ if (KeyPairProvider.SSH_RSA.equals(keyType)) {
+ // Add all available signatures for ssh-rsa.
+ reordered.add(KeyUtils.RSA_SHA512_KEY_TYPE_ALIAS);
+ reordered.add(KeyUtils.RSA_SHA256_KEY_TYPE_ALIAS);
+ }
+ reordered.add(keyType);
+ }
+ }
+ }
+ reordered.addAll(defaultSignatures);
+ if (log.isDebugEnabled()) {
+ log.debug(SshConstants.HOST_KEY_ALGORITHMS + ' ' + reordered);
+ }
+ // Make sure we actually have factories for them all.
+ if (reordered.size() > defaultSignatures.size()) {
+ setNewFactories(defaultSignatures, reordered);
+ }
+ return String.join(",", reordered); //$NON-NLS-1$
+ }
+ if (log.isDebugEnabled()) {
+ log.debug(
+ SshConstants.HOST_KEY_ALGORITHMS + ' ' + defaultSignatures);
+ }
+ return String.join(",", defaultSignatures); //$NON-NLS-1$
+ }
+
+ private List<String> determineKexProposal() {
+ List<KeyExchangeFactory> kexFactories = getKeyExchangeFactories();
+ List<String> defaultKexMethods = NamedResource
+ .getNameList(kexFactories);
+ HostConfigEntry config = resolveAttribute(
+ JGitSshClient.HOST_CONFIG_ENTRY);
+ String algorithms = config.getProperty(SshConstants.KEX_ALGORITHMS);
+ if (!StringUtils.isEmptyOrNull(algorithms)) {
+ Set<String> allAvailable = new HashSet<>();
+ BuiltinDHFactories.VALUES
+ .forEach(s -> allAvailable.add(s.getName()));
+ BuiltinDHFactories.getRegisteredExtensions()
+ .forEach(s -> allAvailable.add(s.getName()));
+ List<String> result = modifyAlgorithmList(defaultKexMethods,
+ allAvailable, algorithms, SshConstants.KEX_ALGORITHMS);
+ if (!result.isEmpty()) {
+ // If new ones were added, update the installed factories
+ Set<String> configuredKexMethods = new HashSet<>(
+ defaultKexMethods);
+ List<KeyExchangeFactory> newKexFactories = new ArrayList<>();
+ result.forEach(name -> {
+ if (!configuredKexMethods.contains(name)) {
+ DHFactory factory = BuiltinDHFactories
+ .resolveFactory(name);
+ if (factory == null) {
+ // Should not occur here
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "determineKexProposal({}) unknown KEX algorithm {} ignored", //$NON-NLS-1$
+ this, name);
+ }
+ } else {
+ newKexFactories
+ .add(ClientBuilder.DH2KEX.apply(factory));
+ }
+ }
+ });
+ if (!newKexFactories.isEmpty()) {
+ newKexFactories.addAll(kexFactories);
+ setKeyExchangeFactories(newKexFactories);
+ }
+ return result;
+ }
+ log.warn(format(SshdText.get().configNoKnownAlgorithms,
+ SshConstants.KEX_ALGORITHMS, algorithms));
+ }
+ return defaultKexMethods;
+ }
+
+ @Override
+ protected String resolveSessionKexProposal(String hostKeyTypes)
+ throws IOException {
+ String kexMethods = String.join(",", determineKexProposal()); //$NON-NLS-1$
+ if (isInitialKex) {
+ // First time
+ KexExtensionHandler extHandler = getKexExtensionHandler();
+ if (extHandler != null && extHandler.isKexExtensionsAvailable(this,
+ AvailabilityPhase.PROPOSAL)) {
+ if (kexMethods.isEmpty()) {
+ kexMethods = KexExtensions.CLIENT_KEX_EXTENSION;
+ } else {
+ kexMethods += ',' + KexExtensions.CLIENT_KEX_EXTENSION;
+ }
+ }
+ isInitialKex = false;
+ }
+ if (log.isDebugEnabled()) {
+ log.debug(SshConstants.KEX_ALGORITHMS + ' ' + kexMethods);
+ }
+ return kexMethods;
+ }
+
+ @Override
+ public List<NamedFactory<Cipher>> getCipherFactories() {
+ if (ciphers == null) {
+ List<NamedFactory<Cipher>> defaultCiphers = super.getCipherFactories();
+ HostConfigEntry config = resolveAttribute(
+ JGitSshClient.HOST_CONFIG_ENTRY);
+ String algorithms = config.getProperty(SshConstants.CIPHERS);
+ if (!StringUtils.isEmptyOrNull(algorithms)) {
+ List<String> defaultCipherNames = defaultCiphers
+ .stream().map(NamedFactory::getName)
+ .collect(Collectors.toCollection(ArrayList::new));
+ Set<String> allKnownCiphers = new HashSet<>();
+ BuiltinCiphers.VALUES.stream()
+ .filter(c -> !FORBIDDEN_CIPHERS.contains(c))
+ .filter(CipherFactory::isSupported)
+ .forEach(c -> allKnownCiphers.add(c.getName()));
+ BuiltinCiphers.getRegisteredExtensions().stream()
+ .filter(CipherFactory::isSupported)
+ .forEach(c -> allKnownCiphers.add(c.getName()));
+ List<String> sessionCipherNames = modifyAlgorithmList(
+ defaultCipherNames, allKnownCiphers, algorithms,
+ SshConstants.CIPHERS);
+ if (sessionCipherNames.isEmpty()) {
+ log.warn(format(SshdText.get().configNoKnownAlgorithms,
+ SshConstants.CIPHERS, algorithms));
+ ciphers = defaultCiphers;
+ } else {
+ List<NamedFactory<Cipher>> sessionCiphers = new ArrayList<>(
+ sessionCipherNames.size());
+ sessionCipherNames.forEach(name -> sessionCiphers
+ .add(BuiltinCiphers.resolveFactory(name)));
+ ciphers = sessionCiphers;
+ }
+ } else {
+ ciphers = defaultCiphers;
+ }
+ }
+ return ciphers;
+ }
+
+ /**
+ * Modifies a given algorithm list according to a list from the ssh config,
+ * including add ('+'), remove ('-') and reordering ('^') operators.
+ *
+ * @param defaultList
+ * to modify
+ * @param allAvailable
+ * all available values
+ * @param fromConfig
+ * telling how to modify the {@code defaultList}, must not be
+ * {@code null} or empty
+ * @param overrideKey
+ * ssh config key; used for logging
+ * @return the modified list or {@code null} if {@code overrideKey} is not
+ * set
+ */
+ public List<String> modifyAlgorithmList(List<String> defaultList,
+ Set<String> allAvailable, String fromConfig, String overrideKey) {
+ Set<String> defaults = new LinkedHashSet<>();
+ defaults.addAll(defaultList);
+ switch (fromConfig.charAt(0)) {
+ case '+':
+ List<String> newSignatures = filteredList(allAvailable, overrideKey,
+ fromConfig.substring(1));
+ defaults.addAll(newSignatures);
+ return new ArrayList<>(defaults);
+ case '-':
+ // This takes wildcard patterns!
+ removeFromList(defaults, overrideKey, fromConfig.substring(1));
+ return new ArrayList<>(defaults);
+ case '^':
+ // Specified entries go to the front of the default list
+ List<String> allSignatures = filteredList(allAvailable, overrideKey,
+ fromConfig.substring(1));
+ Set<String> atFront = new HashSet<>(allSignatures);
+ for (String sig : defaults) {
+ if (!atFront.contains(sig)) {
+ allSignatures.add(sig);
+ }
+ }
+ return allSignatures;
+ default:
+ // Default is overridden -- only accept the ones for which we do
+ // have an implementation.
+ return filteredList(allAvailable, overrideKey, fromConfig);
+ }
+ }
+
+ private void removeFromList(Set<String> current, String key,
+ String patterns) {
+ for (String toRemove : patterns.split("\\s*,\\s*")) { //$NON-NLS-1$
+ if (toRemove.indexOf('*') < 0 && toRemove.indexOf('?') < 0) {
+ current.remove(toRemove);
+ continue;
+ }
+ try {
+ FileNameMatcher matcher = new FileNameMatcher(toRemove, null);
+ for (Iterator<String> i = current.iterator(); i.hasNext();) {
+ matcher.reset();
+ matcher.append(i.next());
+ if (matcher.isMatch()) {
+ i.remove();
+ }
+ }
+ } catch (InvalidPatternException e) {
+ log.warn(format(SshdText.get().configInvalidPattern, key,
+ toRemove));
+ }
+ }
+ }
+
+ private List<String> filteredList(Set<String> known, String key,
+ String values) {
+ List<String> newNames = new ArrayList<>();
+ for (String newValue : values.split("\\s*,\\s*")) { //$NON-NLS-1$
+ if (known.contains(newValue)) {
+ newNames.add(newValue);
+ } else {
+ log.warn(format(SshdText.get().configUnknownAlgorithm, this,
+ newValue, key, values));
+ }
+ }
+ return newNames;
+ }
+
+ /**
+ * Reads the RFC 4253, section 4.2 protocol version identification. The
+ * Apache MINA sshd default implementation checks for NUL bytes also in any
+ * preceding lines, whereas RFC 4253 requires such a check only for the
+ * actual identification string starting with "SSH-". Likewise, the 255
+ * character limit exists only for the identification string, not for the
+ * preceding lines. CR-LF handling is also relaxed.
+ *
+ * @param buffer
+ * to read from
+ * @param server
+ * whether we're an SSH server (should always be {@code false})
+ * @return the lines read, with the server identification line last, or
+ * {@code null} if no identification line was found and more bytes
+ * are needed
+ * @throws StreamCorruptedException
+ * if the identification is malformed
+ * @see <a href="https://tools.ietf.org/html/rfc4253#section-4.2">RFC 4253,
+ * section 4.2</a>
+ */
+ @Override
+ protected List<String> doReadIdentification(Buffer buffer, boolean server)
+ throws StreamCorruptedException {
+ if (server) {
+ // Should never happen. No translation; internal bug.
+ throw new IllegalStateException(
+ "doReadIdentification of client called with server=true"); //$NON-NLS-1$
+ }
+ Integer maxIdentLength = MAX_IDENTIFICATION_SIZE.get(this).orElse(null);
+ int maxIdentSize;
+ if (maxIdentLength == null || maxIdentLength
+ .intValue() < DEFAULT_MAX_IDENTIFICATION_SIZE) {
+ maxIdentSize = DEFAULT_MAX_IDENTIFICATION_SIZE;
+ MAX_IDENTIFICATION_SIZE.set(this, Integer.valueOf(maxIdentSize));
+ } else {
+ maxIdentSize = maxIdentLength.intValue();
+ }
+ int current = buffer.rpos();
+ int end = current + buffer.available();
+ if (current >= end) {
+ return null;
+ }
+ byte[] raw = buffer.array();
+ List<String> ident = new ArrayList<>();
+ int start = current;
+ boolean hasNul = false;
+ for (int i = current; i < end; i++) {
+ switch (raw[i]) {
+ case 0:
+ hasNul = true;
+ break;
+ case '\n':
+ int eol = 1;
+ if (i > start && raw[i - 1] == '\r') {
+ eol++;
+ }
+ String line = new String(raw, start, i + 1 - eol - start,
+ StandardCharsets.UTF_8);
+ start = i + 1;
+ if (log.isDebugEnabled()) {
+ log.debug(format("doReadIdentification({0}) line: ", this) + //$NON-NLS-1$
+ escapeControls(line));
+ }
+ ident.add(line);
+ if (line.startsWith("SSH-")) { //$NON-NLS-1$
+ if (hasNul) {
+ throw new StreamCorruptedException(
+ format(SshdText.get().serverIdWithNul,
+ escapeControls(line)));
+ }
+ if (line.length() + eol > 255) {
+ throw new StreamCorruptedException(
+ format(SshdText.get().serverIdTooLong,
+ escapeControls(line)));
+ }
+ buffer.rpos(start);
+ return ident;
+ }
+ // If this were a server, we could throw an exception here: a
+ // client is not supposed to send any extra lines before its
+ // identification string.
+ hasNul = false;
+ break;
+ default:
+ break;
+ }
+ if (i - current + 1 >= maxIdentSize) {
+ String msg = format(SshdText.get().serverIdNotReceived,
+ Integer.toString(maxIdentSize));
+ if (log.isDebugEnabled()) {
+ log.debug(msg);
+ log.debug(buffer.toHex());
+ }
+ throw new StreamCorruptedException(msg);
+ }
+ }
+ // Need more data
+ return null;
+ }
+
+ private static String escapeControls(String s) {
+ StringBuilder b = new StringBuilder();
+ int l = s.length();
+ for (int i = 0; i < l; i++) {
+ char ch = s.charAt(i);
+ if (Character.isISOControl(ch)) {
+ b.append(ch <= 0xF ? "\\u000" : "\\u00") //$NON-NLS-1$ //$NON-NLS-2$
+ .append(Integer.toHexString(ch));
+ } else {
+ b.append(ch);
+ }
+ }
+ return b.toString();
+ }
+
+ @Override
+ public <T> T getAttribute(AttributeKey<T> key) {
+ T value = super.getAttribute(key);
+ if (value == null) {
+ IoSession ioSession = getIoSession();
+ if (ioSession != null) {
+ Object obj = ioSession.getAttribute(AttributeRepository.class);
+ if (obj instanceof AttributeRepository) {
+ AttributeRepository sessionAttributes = (AttributeRepository) obj;
+ value = sessionAttributes.resolveAttribute(key);
+ }
+ }
+ }
+ return value;
+ }
+
+ @Override
+ public PropertyResolver getParentPropertyResolver() {
+ IoSession ioSession = getIoSession();
+ if (ioSession != null) {
+ Object obj = ioSession.getAttribute(AttributeRepository.class);
+ if (obj instanceof PropertyResolver) {
+ return (PropertyResolver) obj;
+ }
+ }
+ return super.getParentPropertyResolver();
+ }
+
+ /**
+ * An {@link AttributeRepository} that chains together two other attribute
+ * sources in a hierarchy.
+ */
+ public static class ChainingAttributes implements AttributeRepository {
+
+ private final AttributeRepository delegate;
+
+ private final AttributeRepository parent;
+
+ /**
+ * Create a new {@link ChainingAttributes} attribute source.
+ *
+ * @param self
+ * to search for attributes first
+ * @param parent
+ * to search for attributes if not found in {@code self}
+ */
+ public ChainingAttributes(AttributeRepository self,
+ AttributeRepository parent) {
+ this.delegate = self;
+ this.parent = parent;
+ }
+
+ @Override
+ public int getAttributesCount() {
+ return delegate.getAttributesCount();
+ }
+
+ @Override
+ public <T> T getAttribute(AttributeKey<T> key) {
+ return delegate.getAttribute(Objects.requireNonNull(key));
+ }
+
+ @Override
+ public Collection<AttributeKey<?>> attributeKeys() {
+ return delegate.attributeKeys();
+ }
+
+ @Override
+ public <T> T resolveAttribute(AttributeKey<T> key) {
+ T value = getAttribute(Objects.requireNonNull(key));
+ if (value == null) {
+ return parent.getAttribute(key);
+ }
+ return value;
+ }
+ }
+
+ /**
+ * A {@link ChainingAttributes} repository that doubles as a
+ * {@link PropertyResolver}. The property map can be set via the attribute
+ * key {@link SessionAttributes#PROPERTIES}.
+ */
+ public static class SessionAttributes extends ChainingAttributes
+ implements PropertyResolver {
+
+ /** Key for storing a map of properties in the attributes. */
+ public static final AttributeKey<Map<String, Object>> PROPERTIES = new AttributeKey<>();
+
+ private final PropertyResolver parentProperties;
+
+ /**
+ * Creates a new {@link SessionAttributes} attribute and property
+ * source.
+ *
+ * @param self
+ * to search for attributes first
+ * @param parent
+ * to search for attributes if not found in {@code self}
+ * @param parentProperties
+ * to search for properties if not found in {@code self}
+ */
+ public SessionAttributes(AttributeRepository self,
+ AttributeRepository parent, PropertyResolver parentProperties) {
+ super(self, parent);
+ this.parentProperties = parentProperties;
+ }
+
+ @Override
+ public PropertyResolver getParentPropertyResolver() {
+ return parentProperties;
+ }
+
+ @Override
+ public Map<String, Object> getProperties() {
+ Map<String, Object> props = getAttribute(PROPERTIES);
+ return props == null ? Collections.emptyMap() : props;
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitHostConfigEntry.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitHostConfigEntry.java
new file mode 100644
index 0000000000..2a8af28f5d
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitHostConfigEntry.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.eclipse.jgit.annotations.NonNull;
+
+/**
+ * A {@link HostConfigEntry} that provides access to the multi-valued keys as
+ * lists of strings. The super class treats them as single strings containing
+ * comma-separated lists.
+ */
+public class JGitHostConfigEntry extends HostConfigEntry {
+
+ private Map<String, List<String>> multiValuedOptions;
+
+ /**
+ * Sets the multi-valued options.
+ *
+ * @param options
+ * to set, may be {@code null} to set an empty map
+ */
+ public void setMultiValuedOptions(Map<String, List<String>> options) {
+ multiValuedOptions = options;
+ }
+
+ /**
+ * Retrieves all multi-valued options.
+ *
+ * @return an unmodifiable map
+ */
+ @NonNull
+ public Map<String, List<String>> getMultiValuedOptions() {
+ Map<String, List<String>> options = multiValuedOptions;
+ if (options == null) {
+ return Collections.emptyMap();
+ }
+ return Collections.unmodifiableMap(options);
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthFactory.java
new file mode 100644
index 0000000000..0e3e24dcff
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthFactory.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import java.io.IOException;
+
+import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey;
+import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyFactory;
+import org.apache.sshd.client.session.ClientSession;
+
+/**
+ * A customized authentication factory for public key user authentication.
+ */
+public class JGitPublicKeyAuthFactory extends UserAuthPublicKeyFactory {
+
+ /** The singleton {@link JGitPublicKeyAuthFactory}. */
+ public static final JGitPublicKeyAuthFactory FACTORY = new JGitPublicKeyAuthFactory();
+
+ private JGitPublicKeyAuthFactory() {
+ super();
+ }
+
+ @Override
+ public UserAuthPublicKey createUserAuth(ClientSession session)
+ throws IOException {
+ return new JGitPublicKeyAuthentication(getSignatureFactories());
+ }
+}
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
new file mode 100644
index 0000000000..6aace4753a
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPublicKeyAuthentication.java
@@ -0,0 +1,579 @@
+/*
+ * 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
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+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.io.StreamCorruptedException;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+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;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.sshd.agent.SshAgent;
+import org.apache.sshd.agent.SshAgentFactory;
+import org.apache.sshd.agent.SshAgentKeyConstraint;
+import org.apache.sshd.client.auth.pubkey.KeyAgentIdentity;
+import org.apache.sshd.client.auth.pubkey.PublicKeyIdentity;
+import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey;
+import org.apache.sshd.client.auth.pubkey.UserAuthPublicKeyIterator;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.FactoryManager;
+import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.config.keys.KeyUtils;
+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 and PKCS11
+ * providers.
+ */
+public class JGitPublicKeyAuthentication extends UserAuthPublicKey {
+
+ private static final String LOG_FORMAT = "{}"; //$NON-NLS-1$
+
+ private SshAgent agent;
+
+ private HostConfigEntry hostConfig;
+
+ private boolean addKeysToAgent;
+
+ private boolean askBeforeAdding;
+
+ private String skProvider;
+
+ private SshAgentKeyConstraint[] constraints;
+
+ JGitPublicKeyAuthentication(List<NamedFactory<Signature>> factories) {
+ super(factories);
+ }
+
+ @Override
+ public void init(ClientSession rawSession, String service)
+ throws Exception {
+ if (!(rawSession instanceof JGitClientSession)) {
+ throw new IllegalStateException("Wrong session type: " //$NON-NLS-1$
+ + rawSession.getClass().getCanonicalName());
+ }
+ JGitClientSession session = (JGitClientSession) rawSession;
+ hostConfig = session.getHostConfigEntry();
+ // Set signature algorithms for public key authentication
+ String pubkeyAlgos = hostConfig.getProperty(PUBKEY_ACCEPTED_ALGORITHMS);
+ if (!StringUtils.isEmptyOrNull(pubkeyAlgos)) {
+ List<String> signatures = session.getSignatureFactoriesNames();
+ signatures = session.modifyAlgorithmList(signatures,
+ session.getAllAvailableSignatureAlgorithms(), pubkeyAlgos,
+ PUBKEY_ACCEPTED_ALGORITHMS);
+ if (!signatures.isEmpty()) {
+ if (log.isDebugEnabled()) {
+ log.debug(PUBKEY_ACCEPTED_ALGORITHMS + ' ' + signatures);
+ }
+ setSignatureFactoriesNames(signatures);
+ super.init(session, service);
+ return;
+ }
+ 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
+ // fixed
+ List<NamedFactory<Signature>> localFactories = getSignatureFactories();
+ if (localFactories == null || localFactories.isEmpty()) {
+ setSignatureFactoriesNames(session.getSignatureFactoriesNames());
+ }
+ super.init(session, service);
+ }
+
+ @Override
+ protected Iterator<PublicKeyIdentity> createPublicKeyIterator(
+ ClientSession session, SignatureFactoriesManager manager)
+ throws Exception {
+ agent = getAgent(session);
+ if (agent != null) {
+ parseAddKeys(hostConfig);
+ if (addKeysToAgent) {
+ skProvider = hostConfig.getProperty(SshConstants.SECURITY_KEY_PROVIDER);
+ }
+ }
+ return new KeyIterator(session, manager);
+ }
+
+ @Override
+ protected PublicKeyIdentity resolveAttemptedPublicKeyIdentity(
+ ClientSession session, String service) throws Exception {
+ PublicKeyIdentity id = super.resolveAttemptedPublicKeyIdentity(session,
+ service);
+ if (addKeysToAgent && id != null && !(id instanceof KeyAgentIdentity)) {
+ KeyPair key = id.getKeyIdentity();
+ if (key != null && key.getPublic() != null
+ && key.getPrivate() != null) {
+ // We've just successfully loaded a key that wasn't in the
+ // agent. Add it to the agent.
+ //
+ // Keys are added after loading, as in OpenSSH. The alternative
+ // might be to add a key only after (partially) successful
+ // authentication?
+ PublicKey pk = key.getPublic();
+ String fingerprint = KeyUtils.getFingerPrint(pk);
+ String keyType = KeyUtils.getKeyType(key);
+ try {
+ // Check that the key is not in the agent already.
+ if (agentHasKey(pk)) {
+ return id;
+ }
+ if (askBeforeAdding
+ && (session instanceof JGitClientSession)) {
+ CredentialsProvider provider = ((JGitClientSession) session)
+ .getCredentialsProvider();
+ CredentialItem.YesNoType question = new CredentialItem.YesNoType(
+ format(SshdText
+ .get().pubkeyAuthAddKeyToAgentQuestion,
+ keyType, fingerprint));
+ boolean result = provider != null
+ && provider.supports(question)
+ && provider.get(getUri(), question);
+ if (!result || !question.getValue()) {
+ // Don't add the key.
+ return id;
+ }
+ }
+ SshAgentKeyConstraint[] rules = constraints;
+ if (pk instanceof SecurityKeyPublicKey && !StringUtils.isEmptyOrNull(skProvider)) {
+ rules = Arrays.copyOf(rules, rules.length + 1);
+ rules[rules.length - 1] =
+ new SshAgentKeyConstraint.FidoProviderExtension(skProvider);
+ }
+ // Unfortunately a comment associated with the key is lost
+ // by Apache MINA sshd, and there is also no way to get the
+ // original file name for keys loaded from a file. So add it
+ // without comment.
+ agent.addIdentity(key, null, rules);
+ } 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_FORMAT,
+ format(SshdText.get().pubkeyAuthAddKeyToAgentError,
+ keyType, fingerprint),
+ e);
+ // Note that as of Win32-OpenSSH 8.6 and Pageant 0.76,
+ // neither can handle key constraints. Pageant fails
+ // gracefully, not adding the key and returning
+ // SSH_AGENT_FAILURE. Win32-OpenSSH closes the connection
+ // without even returning a failure message, which violates
+ // the SSH agent protocol and makes all subsequent requests
+ // to the agent fail.
+ }
+ }
+ }
+ return id;
+ }
+
+ private boolean agentHasKey(PublicKey pk) throws IOException {
+ Iterable<? extends Map.Entry<PublicKey, String>> ids = agent
+ .getIdentities();
+ if (ids == null) {
+ return false;
+ }
+ Iterator<? extends Map.Entry<PublicKey, String>> iter = ids.iterator();
+ while (iter.hasNext()) {
+ if (KeyUtils.compareKeys(iter.next().getKey(), pk)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private URIish getUri() {
+ String uri = SshConstants.SSH_SCHEME + "://"; //$NON-NLS-1$
+ String userName = hostConfig.getUsername();
+ if (!StringUtils.isEmptyOrNull(userName)) {
+ uri += userName + '@';
+ }
+ uri += hostConfig.getHost();
+ int port = hostConfig.getPort();
+ if (port > 0 && port != SshConstants.SSH_DEFAULT_PORT) {
+ uri += ":" + port; //$NON-NLS-1$
+ }
+ try {
+ return new URIish(uri);
+ } catch (URISyntaxException e) {
+ log.error(e.getLocalizedMessage(), e);
+ }
+ return new URIish();
+ }
+
+ private SshAgent getAgent(ClientSession session) throws Exception {
+ FactoryManager manager = Objects.requireNonNull(
+ session.getFactoryManager(), "No session factory manager"); //$NON-NLS-1$
+ SshAgentFactory factory = manager.getAgentFactory();
+ if (factory == null) {
+ return null;
+ }
+ return factory.createClient(session, manager);
+ }
+
+ private void parseAddKeys(HostConfigEntry config) {
+ String value = config.getProperty(SshConstants.ADD_KEYS_TO_AGENT);
+ if (StringUtils.isEmptyOrNull(value)) {
+ addKeysToAgent = false;
+ return;
+ }
+ String[] values = value.split(","); //$NON-NLS-1$
+ List<SshAgentKeyConstraint> rules = new ArrayList<>(2);
+ switch (values[0]) {
+ case "yes": //$NON-NLS-1$
+ addKeysToAgent = true;
+ break;
+ case "no": //$NON-NLS-1$
+ addKeysToAgent = false;
+ break;
+ case "ask": //$NON-NLS-1$
+ addKeysToAgent = true;
+ askBeforeAdding = true;
+ break;
+ case "confirm": //$NON-NLS-1$
+ addKeysToAgent = true;
+ rules.add(SshAgentKeyConstraint.CONFIRM);
+ if (values.length > 1) {
+ int seconds = OpenSshConfigFile.timeSpec(values[1]);
+ if (seconds > 0) {
+ rules.add(new SshAgentKeyConstraint.LifeTime(seconds));
+ }
+ }
+ break;
+ default:
+ int seconds = OpenSshConfigFile.timeSpec(values[0]);
+ if (seconds > 0) {
+ addKeysToAgent = true;
+ rules.add(new SshAgentKeyConstraint.LifeTime(seconds));
+ }
+ break;
+ }
+ constraints = rules.toArray(new SshAgentKeyConstraint[0]);
+ }
+
+ @Override
+ protected void releaseKeys() throws IOException {
+ addKeysToAgent = false;
+ askBeforeAdding = false;
+ skProvider = null;
+ constraints = null;
+ try {
+ if (agent != null) {
+ try {
+ agent.close();
+ } finally {
+ agent = null;
+ }
+ }
+ } finally {
+ super.releaseKeys();
+ }
+ }
+
+ private class KeyIterator extends UserAuthPublicKeyIterator {
+
+ private static final String PUB_KEY_SUFFIX = ".pub"; //$NON-NLS-1$
+
+ public KeyIterator(ClientSession session,
+ SignatureFactoriesManager manager)
+ throws Exception {
+ super(session, manager);
+ }
+
+ private List<PublicKey> getExplicitKeys(
+ Collection<String> explicitFiles) {
+ if (explicitFiles == null) {
+ return null;
+ }
+ return explicitFiles.stream().map(s -> {
+ // assume the explicit key is a public key
+ PublicKey publicKey = readPublicKey(s, false);
+ if (publicKey == null && !s.endsWith(PUB_KEY_SUFFIX)) {
+ // if this is not the case, try to lookup public key with
+ // same filename and extension .pub
+ publicKey = readPublicKey(s + PUB_KEY_SUFFIX, true);
+ }
+ return publicKey;
+ }).filter(Objects::nonNull).collect(Collectors.toList());
+ }
+
+ /**
+ *
+ * @param keyFile
+ * the path to a public key
+ * @param isDerived
+ * {@code false} in case the given {@code keyFile} is set in
+ * the SSH config as is, otherwise {@code false}
+ * @return the public key read from the key file
+ */
+ private PublicKey readPublicKey(String keyFile, boolean isDerived) {
+ try {
+ Path p = Paths.get(keyFile);
+ if (Files.isRegularFile(p, LinkOption.NOFOLLOW_LINKS)) {
+ return KeyUtils.loadPublicKey(p);
+ }
+ // only warn about non-existing files in case the key file is
+ // not derived
+ if (!isDerived) {
+ log.warn(LOG_FORMAT,
+ format(SshdText.get().cannotReadPublicKey, keyFile));
+ }
+ } catch (GeneralSecurityException | StreamCorruptedException e) {
+ // ignore in case this is not a derived key path, as in most
+ // cases this specifies a private key
+ if (isDerived) {
+ log.warn(LOG_FORMAT,
+ format(SshdText.get().cannotReadPublicKey, keyFile),
+ e);
+ }
+ } catch (InvalidPathException | IOException e) {
+ log.warn(LOG_FORMAT,
+ format(SshdText.get().cannotReadPublicKey, keyFile), e);
+ }
+ return null;
+ }
+
+ @Override
+ protected Iterable<KeyAgentIdentity> initializeAgentIdentities(
+ ClientSession session) throws IOException {
+ Iterable<KeyAgentIdentity> allAgentKeys = getAgentIdentities();
+ if (allAgentKeys == null) {
+ return null;
+ }
+ 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<KeyAgentIdentity> identities = allAgentKeys
+ .iterator();
+
+ private KeyAgentIdentity next;
+
+ @Override
+ public boolean hasNext() {
+ 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.stream()
+ .anyMatch(k -> KeyUtils.compareKeys(k, pk))) {
+ next = val;
+ return true;
+ }
+ if (log.isTraceEnabled()) {
+ log.trace(
+ "Ignoring SSH agent or PKCS11 {} key not in explicit IdentityFile in SSH config: {}", //$NON-NLS-1$
+ KeyUtils.getKeyType(pk),
+ KeyUtils.getFingerPrint(pk));
+ }
+ }
+ return next != null;
+ }
+
+ @Override
+ public KeyAgentIdentity next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ 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/JGitServerKeyVerifier.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitServerKeyVerifier.java
new file mode 100644
index 0000000000..622c1a528c
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitServerKeyVerifier.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2019 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;
+
+import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.flag;
+
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.security.PublicKey;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.config.hosts.KnownHostHashValue;
+import org.apache.sshd.client.keyverifier.ServerKeyVerifier;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.SshConstants;
+import org.eclipse.jgit.transport.sshd.ServerKeyDatabase;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A bridge between the {@link ServerKeyVerifier} from Apache MINA sshd and our
+ * {@link ServerKeyDatabase}.
+ */
+public class JGitServerKeyVerifier
+ implements ServerKeyVerifier, ServerKeyLookup {
+
+ private static final Logger LOG = LoggerFactory
+ .getLogger(JGitServerKeyVerifier.class);
+
+ private final @NonNull ServerKeyDatabase database;
+
+ /**
+ * Creates a new {@link JGitServerKeyVerifier} using the given
+ * {@link ServerKeyDatabase}.
+ *
+ * @param database
+ * to use
+ */
+ public JGitServerKeyVerifier(@NonNull ServerKeyDatabase database) {
+ this.database = database;
+ }
+
+ @Override
+ public List<PublicKey> lookup(ClientSession session,
+ SocketAddress remoteAddress) {
+ if (!(session instanceof JGitClientSession)) {
+ LOG.warn("Internal error: wrong session kind: " //$NON-NLS-1$
+ + session.getClass().getName());
+ return Collections.emptyList();
+ }
+ if (!(remoteAddress instanceof InetSocketAddress)) {
+ return Collections.emptyList();
+ }
+ SessionConfig config = new SessionConfig((JGitClientSession) session);
+ SshdSocketAddress connectAddress = SshdSocketAddress
+ .toSshdSocketAddress(session.getConnectAddress());
+ String connect = KnownHostHashValue.createHostPattern(
+ connectAddress.getHostName(), connectAddress.getPort());
+ return database.lookup(connect, (InetSocketAddress) remoteAddress,
+ config);
+ }
+
+ @Override
+ public boolean verifyServerKey(ClientSession session,
+ SocketAddress remoteAddress, PublicKey serverKey) {
+ if (!(session instanceof JGitClientSession)) {
+ LOG.warn("Internal error: wrong session kind: " //$NON-NLS-1$
+ + session.getClass().getName());
+ return false;
+ }
+ if (!(remoteAddress instanceof InetSocketAddress)) {
+ return false;
+ }
+ SessionConfig config = new SessionConfig((JGitClientSession) session);
+ SshdSocketAddress connectAddress = SshdSocketAddress
+ .toSshdSocketAddress(session.getConnectAddress());
+ String connect = KnownHostHashValue.createHostPattern(
+ connectAddress.getHostName(), connectAddress.getPort());
+ CredentialsProvider provider = ((JGitClientSession) session)
+ .getCredentialsProvider();
+ return database.accept(connect, (InetSocketAddress) remoteAddress,
+ serverKey, config, provider);
+ }
+
+ private static class SessionConfig
+ implements ServerKeyDatabase.Configuration {
+
+ private final JGitClientSession session;
+
+ public SessionConfig(JGitClientSession session) {
+ this.session = session;
+ }
+
+ private List<String> get(String key) {
+ HostConfigEntry entry = session.getHostConfigEntry();
+ if (entry instanceof JGitHostConfigEntry) {
+ // Always true!
+ return ((JGitHostConfigEntry) entry).getMultiValuedOptions()
+ .get(key);
+ }
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List<String> getUserKnownHostsFiles() {
+ return get(SshConstants.USER_KNOWN_HOSTS_FILE);
+ }
+
+ @Override
+ public List<String> getGlobalKnownHostsFiles() {
+ return get(SshConstants.GLOBAL_KNOWN_HOSTS_FILE);
+ }
+
+ @Override
+ public StrictHostKeyChecking getStrictHostKeyChecking() {
+ HostConfigEntry entry = session.getHostConfigEntry();
+ String value = entry
+ .getProperty(SshConstants.STRICT_HOST_KEY_CHECKING, "ask"); //$NON-NLS-1$
+ switch (value.toLowerCase(Locale.ROOT)) {
+ case SshConstants.YES:
+ case SshConstants.ON:
+ return StrictHostKeyChecking.REQUIRE_MATCH;
+ case SshConstants.NO:
+ case SshConstants.OFF:
+ return StrictHostKeyChecking.ACCEPT_ANY;
+ case "accept-new": //$NON-NLS-1$
+ return StrictHostKeyChecking.ACCEPT_NEW;
+ default:
+ return StrictHostKeyChecking.ASK;
+ }
+ }
+
+ @Override
+ public boolean getHashKnownHosts() {
+ HostConfigEntry entry = session.getHostConfigEntry();
+ return flag(entry.getProperty(SshConstants.HASH_KNOWN_HOSTS));
+ }
+
+ @Override
+ public String getUsername() {
+ return session.getUsername();
+ }
+ }
+}
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
new file mode 100644
index 0000000000..6e9bd621d2
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
@@ -0,0 +1,490 @@
+/*
+ * Copyright (C) 2018, 2022 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;
+
+import static java.text.MessageFormat.format;
+import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS;
+import static org.apache.sshd.core.CoreModuleProperties.PREFERRED_AUTHS;
+import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.net.SocketAddress;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+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;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.future.ConnectFuture;
+import org.apache.sshd.client.future.DefaultConnectFuture;
+import org.apache.sshd.client.session.ClientSessionImpl;
+import org.apache.sshd.client.session.SessionFactory;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.apache.sshd.common.future.SshFutureListener;
+import org.apache.sshd.common.io.IoConnectFuture;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider;
+import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
+import org.apache.sshd.common.session.SessionContext;
+import org.apache.sshd.common.session.helpers.AbstractSession;
+import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.ChainingAttributes;
+import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
+import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
+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;
+
+/**
+ * Customized {@link SshClient} for JGit. It creates specialized
+ * {@link JGitClientSession}s that know about the {@link HostConfigEntry} they
+ * were created for, and it loads all KeyPair identities lazily.
+ */
+public class JGitSshClient extends SshClient {
+
+ /**
+ * We need access to this during the constructor of the ClientSession,
+ * before setConnectAddress() can have been called. So we have to remember
+ * it in an attribute on the SshClient, from where we can then retrieve it.
+ */
+ static final AttributeKey<HostConfigEntry> HOST_CONFIG_ENTRY = new AttributeKey<>();
+
+ static final AttributeKey<InetSocketAddress> ORIGINAL_REMOTE_ADDRESS = new AttributeKey<>();
+
+ /**
+ * An attribute key for the comma-separated list of default preferred
+ * authentication mechanisms.
+ */
+ public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
+
+ /**
+ * An attribute key for the home directory.
+ */
+ public static final AttributeKey<Path> HOME_DIRECTORY = new AttributeKey<>();
+
+ /**
+ * An attribute key for storing an alternate local address to connect to if
+ * a local forward from a ProxyJump ssh config is present. If set,
+ * {@link #connect(HostConfigEntry, AttributeRepository, SocketAddress)}
+ * will not connect to the address obtained from the {@link HostConfigEntry}
+ * but to the address stored in this key (which is assumed to forward the
+ * {@code HostConfigEntry} address).
+ */
+ public static final AttributeKey<SshdSocketAddress> LOCAL_FORWARD_ADDRESS = new AttributeKey<>();
+
+ private KeyCache keyCache;
+
+ private CredentialsProvider credentialsProvider;
+
+ private Supplier<KeyPasswordProvider> keyPasswordProviderFactory;
+
+ private ProxyDataFactory proxyDatabase;
+
+ @Override
+ protected SessionFactory createSessionFactory() {
+ // Override the parent's default
+ return new JGitSessionFactory(this);
+ }
+
+ @Override
+ public ConnectFuture connect(HostConfigEntry hostConfig,
+ AttributeRepository context, SocketAddress localAddress)
+ throws IOException {
+ if (connector == null) {
+ throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
+ }
+ Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
+ String originalHost = ValidateUtils.checkNotNullAndNotEmpty(
+ hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
+ int originalPort = hostConfig.getPort();
+ ValidateUtils.checkTrue(originalPort > 0, "Invalid port: %d", //$NON-NLS-1$
+ originalPort);
+ InetSocketAddress originalAddress = new InetSocketAddress(originalHost,
+ originalPort);
+ InetSocketAddress targetAddress = originalAddress;
+ String userName = hostConfig.getUsername();
+ String id = userName + '@' + originalAddress;
+ AttributeRepository attributes = chain(context, this);
+ SshdSocketAddress localForward = attributes
+ .resolveAttribute(LOCAL_FORWARD_ADDRESS);
+ if (localForward != null) {
+ targetAddress = new InetSocketAddress(localForward.getHostName(),
+ localForward.getPort());
+ id += '/' + targetAddress.toString();
+ }
+ ConnectFuture connectFuture = new DefaultConnectFuture(id, null);
+ SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
+ connectFuture, userName, originalAddress, hostConfig);
+ attributes = sessionAttributes(attributes, hostConfig, originalAddress);
+ // Proxy support
+ if (localForward == null) {
+ ProxyData proxy = getProxyData(targetAddress);
+ if (proxy != null) {
+ targetAddress = configureProxy(proxy, targetAddress);
+ proxy.clearPassword();
+ }
+ }
+ connector.connect(targetAddress, attributes, localAddress)
+ .addListener(listener);
+ return connectFuture;
+ }
+
+ private AttributeRepository chain(AttributeRepository self,
+ AttributeRepository parent) {
+ if (self == null) {
+ return Objects.requireNonNull(parent);
+ }
+ if (parent == null || parent == self) {
+ return self;
+ }
+ return new ChainingAttributes(self, parent);
+ }
+
+ private AttributeRepository sessionAttributes(AttributeRepository parent,
+ HostConfigEntry hostConfig, InetSocketAddress originalAddress) {
+ // sshd needs some entries from the host config already in the
+ // constructor of the session. Put those into a dedicated
+ // AttributeRepository for the new session where it will find them.
+ // We can set the host config only once the session object has been
+ // created.
+ Map<AttributeKey<?>, Object> data = new HashMap<>();
+ data.put(HOST_CONFIG_ENTRY, hostConfig);
+ data.put(ORIGINAL_REMOTE_ADDRESS, originalAddress);
+ data.put(TARGET_SERVER, new SshdSocketAddress(originalAddress));
+ String preferredAuths = hostConfig.getProperty(
+ SshConstants.PREFERRED_AUTHENTICATIONS,
+ resolveAttribute(PREFERRED_AUTHENTICATIONS));
+ if (!StringUtils.isEmptyOrNull(preferredAuths)) {
+ data.put(SessionAttributes.PROPERTIES,
+ Collections.singletonMap(
+ PREFERRED_AUTHS.getName(),
+ preferredAuths));
+ }
+ return new SessionAttributes(
+ AttributeRepository.ofAttributesMap(data),
+ parent, this);
+ }
+
+ private ProxyData getProxyData(InetSocketAddress remoteAddress) {
+ ProxyDataFactory factory = getProxyDatabase();
+ return factory == null ? null : factory.get(remoteAddress);
+ }
+
+ private InetSocketAddress configureProxy(ProxyData proxyData,
+ InetSocketAddress remoteAddress) {
+ Proxy proxy = proxyData.getProxy();
+ if (proxy.type() == Proxy.Type.DIRECT
+ || !(proxy.address() instanceof InetSocketAddress)) {
+ return remoteAddress;
+ }
+ InetSocketAddress address = (InetSocketAddress) proxy.address();
+ if (address.isUnresolved()) {
+ address = new InetSocketAddress(address.getHostName(),
+ address.getPort());
+ }
+ switch (proxy.type()) {
+ case HTTP:
+ setClientProxyConnector(
+ new HttpClientConnector(address, remoteAddress,
+ proxyData.getUser(), proxyData.getPassword()));
+ return address;
+ case SOCKS:
+ setClientProxyConnector(
+ new Socks5ClientConnector(address, remoteAddress,
+ proxyData.getUser(), proxyData.getPassword()));
+ return address;
+ default:
+ log.warn(format(SshdText.get().unknownProxyProtocol,
+ proxy.type().name()));
+ return remoteAddress;
+ }
+ }
+
+ private SshFutureListener<IoConnectFuture> createConnectCompletionListener(
+ ConnectFuture connectFuture, String username,
+ InetSocketAddress address, HostConfigEntry hostConfig) {
+ return new SshFutureListener<>() {
+
+ @Override
+ public void operationComplete(IoConnectFuture future) {
+ if (future.isCanceled()) {
+ connectFuture.cancel();
+ return;
+ }
+ Throwable t = future.getException();
+ if (t != null) {
+ connectFuture.setException(t);
+ return;
+ }
+ IoSession ioSession = future.getSession();
+ try {
+ JGitClientSession session = createSession(ioSession,
+ username, address, hostConfig);
+ connectFuture.setSession(session);
+ } catch (RuntimeException e) {
+ connectFuture.setException(e);
+ ioSession.close(true);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "JGitSshClient$ConnectCompletionListener[" + username //$NON-NLS-1$
+ + '@' + address + ']';
+ }
+ };
+ }
+
+ private JGitClientSession createSession(IoSession ioSession,
+ String username, InetSocketAddress address,
+ HostConfigEntry hostConfig) {
+ AbstractSession rawSession = AbstractSession.getSession(ioSession);
+ if (!(rawSession instanceof JGitClientSession)) {
+ throw new IllegalStateException("Wrong session type: " //$NON-NLS-1$
+ + rawSession.getClass().getCanonicalName());
+ }
+ JGitClientSession session = (JGitClientSession) rawSession;
+ session.setUsername(username);
+ session.setConnectAddress(address);
+ session.setHostConfigEntry(hostConfig);
+ if (session.getCredentialsProvider() == null) {
+ session.setCredentialsProvider(getCredentialsProvider());
+ }
+ 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 {
+ return Paths.get(s);
+ } catch (InvalidPathException e) {
+ log.warn(format(SshdText.get().configInvalidPath,
+ SshConstants.IDENTITY_FILE, s), e);
+ return null;
+ }
+ }).filter(p -> p != null && Files.exists(p))
+ .collect(Collectors.toList());
+ CachingKeyPairProvider ourConfiguredKeysProvider = new CachingKeyPairProvider(
+ identities, keyCache);
+ FilePasswordProvider passwordProvider = getFilePasswordProvider();
+ ourConfiguredKeysProvider.setPasswordFinder(passwordProvider);
+ if (hostConfig.isIdentitiesOnly()) {
+ session.setKeyIdentityProvider(ourConfiguredKeysProvider);
+ } else {
+ KeyIdentityProvider defaultKeysProvider = getKeyIdentityProvider();
+ if (defaultKeysProvider instanceof AbstractResourceKeyPairProvider<?>) {
+ ((AbstractResourceKeyPairProvider<?>) defaultKeysProvider)
+ .setPasswordFinder(passwordProvider);
+ }
+ KeyIdentityProvider combinedProvider = new CombinedKeyIdentityProvider(
+ ourConfiguredKeysProvider, defaultKeysProvider);
+ session.setKeyIdentityProvider(combinedProvider);
+ }
+ return session;
+ }
+
+ private int getNumberOfPasswordPrompts(HostConfigEntry hostConfig) {
+ String prompts = hostConfig
+ .getProperty(SshConstants.NUMBER_OF_PASSWORD_PROMPTS);
+ if (prompts != null) {
+ prompts = prompts.trim();
+ int value = positive(prompts);
+ if (value > 0) {
+ return value;
+ }
+ log.warn(format(SshdText.get().configInvalidPositive,
+ SshConstants.NUMBER_OF_PASSWORD_PROMPTS, prompts));
+ }
+ return PASSWORD_PROMPTS.getRequiredDefault().intValue();
+ }
+
+ /**
+ * Set a cache for loaded keys. Newly discovered keys will be added when
+ * IdentityFile host entries from the ssh config file are used during
+ * session authentication.
+ *
+ * @param cache
+ * to use
+ */
+ public void setKeyCache(KeyCache cache) {
+ keyCache = cache;
+ }
+
+ /**
+ * Sets a {@link ProxyDataFactory} for connecting through proxies.
+ *
+ * @param factory
+ * to use, or {@code null} if proxying is not desired or
+ * supported
+ */
+ public void setProxyDatabase(ProxyDataFactory factory) {
+ proxyDatabase = factory;
+ }
+
+ /**
+ * Retrieves the {@link ProxyDataFactory}.
+ *
+ * @return the factory, or {@code null} if none is set
+ */
+ protected ProxyDataFactory getProxyDatabase() {
+ return proxyDatabase;
+ }
+
+ /**
+ * Sets the {@link CredentialsProvider} for this client.
+ *
+ * @param provider
+ * to set
+ */
+ public void setCredentialsProvider(CredentialsProvider provider) {
+ credentialsProvider = provider;
+ }
+
+ /**
+ * Retrieves the {@link CredentialsProvider} set for this client.
+ *
+ * @return the provider, or {@code null} if none is set.
+ */
+ public CredentialsProvider getCredentialsProvider() {
+ return credentialsProvider;
+ }
+
+ /**
+ * 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.
+ */
+ private static class JGitSessionFactory extends SessionFactory {
+
+ public JGitSessionFactory(JGitSshClient client) {
+ super(client);
+ }
+
+ @Override
+ protected ClientSessionImpl doCreateSession(IoSession ioSession)
+ throws Exception {
+ return new JGitClientSession(getClient(), ioSession);
+ }
+ }
+
+ /**
+ * A {@link KeyIdentityProvider} that iterates over the {@link Iterable}s
+ * returned by other {@link KeyIdentityProvider}s.
+ */
+ private static class CombinedKeyIdentityProvider
+ implements KeyIdentityProvider {
+
+ private final List<KeyIdentityProvider> providers;
+
+ public CombinedKeyIdentityProvider(KeyIdentityProvider... providers) {
+ this(Arrays.stream(providers).filter(Objects::nonNull)
+ .collect(Collectors.toList()));
+ }
+
+ public CombinedKeyIdentityProvider(
+ List<KeyIdentityProvider> providers) {
+ this.providers = providers;
+ }
+
+ @Override
+ public Iterable<KeyPair> loadKeys(SessionContext context) {
+ return () -> new Iterator<>() {
+
+ private Iterator<KeyIdentityProvider> factories = providers
+ .iterator();
+ private Iterator<KeyPair> current;
+
+ private Boolean hasElement;
+
+ @Override
+ public boolean hasNext() {
+ if (hasElement != null) {
+ return hasElement.booleanValue();
+ }
+ while (current == null || !current.hasNext()) {
+ if (factories.hasNext()) {
+ try {
+ current = factories.next().loadKeys(context)
+ .iterator();
+ } catch (IOException | GeneralSecurityException e) {
+ throw new RuntimeException(e);
+ }
+ } else {
+ current = null;
+ hasElement = Boolean.FALSE;
+ return false;
+ }
+ }
+ hasElement = Boolean.TRUE;
+ return true;
+ }
+
+ @Override
+ public KeyPair next() {
+ if ((hasElement == null && !hasNext())
+ || !hasElement.booleanValue()) {
+ throw new NoSuchElementException();
+ }
+ hasElement = null;
+ KeyPair result;
+ try {
+ result = current.next();
+ } catch (NoSuchElementException e) {
+ result = null;
+ }
+ return result;
+ }
+
+ };
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java
new file mode 100644
index 0000000000..6b0d9fb70b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshConfig.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2018, 2020 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;
+
+import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.flag;
+import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive;
+
+import java.io.IOException;
+import java.net.SocketAddress;
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.eclipse.jgit.transport.SshConfigStore;
+import org.eclipse.jgit.transport.SshConstants;
+import org.eclipse.jgit.transport.SshSessionFactory;
+
+/**
+ * A bridge between a JGit {@link SshConfigStore} and the Apache MINA sshd
+ * {@link HostConfigEntryResolver}.
+ */
+public class JGitSshConfig implements HostConfigEntryResolver {
+
+ private final SshConfigStore configFile;
+
+ /**
+ * Creates a new {@link JGitSshConfig} that will read the config from the
+ * given {@link SshConfigStore}.
+ *
+ * @param store
+ * to use
+ */
+ public JGitSshConfig(SshConfigStore store) {
+ configFile = store;
+ }
+
+ @Override
+ public HostConfigEntry resolveEffectiveHost(String host, int port,
+ SocketAddress localAddress, String username, String proxyJump,
+ AttributeRepository attributes) throws IOException {
+ SshConfigStore.HostConfig entry = configFile == null
+ ? SshConfigStore.EMPTY_CONFIG
+ : configFile.lookup(host, port, username);
+ JGitHostConfigEntry config = new JGitHostConfigEntry();
+ // Apache MINA conflates all keys, even multi-valued ones, in one map
+ // and puts multiple values separated by commas in one string. See
+ // the javadoc on HostConfigEntry.
+ Map<String, String> allOptions = new TreeMap<>(
+ String.CASE_INSENSITIVE_ORDER);
+ allOptions.putAll(entry.getOptions());
+ // And what if a value contains a comma??
+ entry.getMultiValuedOptions().entrySet().stream()
+ .forEach(e -> allOptions.put(e.getKey(),
+ String.join(",", e.getValue()))); //$NON-NLS-1$
+ config.setProperties(allOptions);
+ // The following is an extension from JGitHostConfigEntry
+ config.setMultiValuedOptions(entry.getMultiValuedOptions());
+ // Also make sure the underlying properties are set
+ String hostName = entry.getValue(SshConstants.HOST_NAME);
+ if (hostName == null || hostName.isEmpty()) {
+ hostName = host;
+ }
+ config.setHostName(hostName);
+ config.setProperty(SshConstants.HOST_NAME, hostName);
+ config.setHost(SshdSocketAddress.isIPv6Address(hostName) ? "" : hostName); //$NON-NLS-1$
+ String user = username != null && !username.isEmpty() ? username
+ : entry.getValue(SshConstants.USER);
+ if (user == null || user.isEmpty()) {
+ user = SshSessionFactory.getLocalUserName();
+ }
+ config.setUsername(user);
+ config.setProperty(SshConstants.USER, user);
+ int p = port >= 0 ? port : positive(entry.getValue(SshConstants.PORT));
+ config.setPort(p >= 0 ? p : SshConstants.SSH_DEFAULT_PORT);
+ config.setProperty(SshConstants.PORT,
+ Integer.toString(config.getPort()));
+ config.setIdentities(entry.getValues(SshConstants.IDENTITY_FILE));
+ config.setIdentitiesOnly(
+ flag(entry.getValue(SshConstants.IDENTITIES_ONLY)));
+ return config;
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java
new file mode 100644
index 0000000000..2a725ea16a
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2018, 2022 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;
+
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.sshd.client.auth.keyboard.UserInteraction;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.SessionListener;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.SshConstants;
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * A {@link UserInteraction} callback implementation based on a
+ * {@link CredentialsProvider}.
+ */
+public class JGitUserInteraction implements UserInteraction {
+
+ private final CredentialsProvider provider;
+
+ /**
+ * We need to reset the JGit credentials provider if we have repeated
+ * attempts.
+ */
+ private final Map<Session, SessionListener> ongoing = new ConcurrentHashMap<>();
+
+ /**
+ * Creates a new {@link JGitUserInteraction} for interactive password input
+ * based on the given {@link CredentialsProvider}.
+ *
+ * @param provider
+ * to use
+ */
+ public JGitUserInteraction(CredentialsProvider provider) {
+ this.provider = provider;
+ }
+
+ @Override
+ public boolean isInteractionAllowed(ClientSession session) {
+ return provider != null && provider.isInteractive();
+ }
+
+ @Override
+ public String[] interactive(ClientSession session, String name,
+ String instruction, String lang, String[] prompt, boolean[] echo) {
+ // This is keyboard-interactive or password authentication
+ List<CredentialItem> items = new ArrayList<>();
+ int numberOfHiddenInputs = 0;
+ for (int i = 0; i < prompt.length; i++) {
+ boolean hidden = i < echo.length && !echo[i];
+ if (hidden) {
+ numberOfHiddenInputs++;
+ }
+ }
+ // RFC 4256 (SSH_MSG_USERAUTH_INFO_REQUEST) says: "The language tag is
+ // deprecated and SHOULD be the empty string." and "[If there are no
+ // prompts] the client SHOULD still display the name and instruction
+ // fields" and "[The] client SHOULD print the name and instruction (if
+ // non-empty)"
+ if (name != null && !name.isEmpty()) {
+ items.add(new CredentialItem.InformationalMessage(name));
+ }
+ if (instruction != null && !instruction.isEmpty()) {
+ items.add(new CredentialItem.InformationalMessage(instruction));
+ }
+ for (int i = 0; i < prompt.length; i++) {
+ boolean hidden = i < echo.length && !echo[i];
+ if (hidden && numberOfHiddenInputs == 1) {
+ // We need to somehow trigger storing the password in the
+ // Eclipse secure storage in EGit. Currently, this is done only
+ // for password fields.
+ items.add(new CredentialItem.Password());
+ // TODO Possibly change EGit to store all hidden strings
+ // (keyed by the URI and the prompt?) so that we don't have to
+ // use this kludge here.
+ } else {
+ items.add(new CredentialItem.StringType(prompt[i], hidden));
+ }
+ }
+ if (items.isEmpty()) {
+ // Huh? No info, no prompts?
+ return prompt; // Is known to have length zero here
+ }
+ URIish uri = toURI(session.getUsername(),
+ (InetSocketAddress) session.getConnectAddress());
+ // Reset the provider for this URI if it's not the first attempt and we
+ // have hidden inputs. Otherwise add a session listener that will remove
+ // itself once authenticated.
+ if (numberOfHiddenInputs > 0) {
+ SessionListener listener = ongoing.get(session);
+ if (listener != null) {
+ provider.reset(uri);
+ } else {
+ listener = new SessionAuthMarker(ongoing);
+ ongoing.put(session, listener);
+ session.addSessionListener(listener);
+ }
+ }
+ if (provider.get(uri, items)) {
+ return items.stream().map(i -> {
+ if (i instanceof CredentialItem.Password) {
+ return new String(((CredentialItem.Password) i).getValue());
+ } else if (i instanceof CredentialItem.StringType) {
+ return ((CredentialItem.StringType) i).getValue();
+ }
+ return null;
+ }).filter(s -> s != null).toArray(String[]::new);
+ }
+ throw new AuthenticationCanceledException();
+ }
+
+ @Override
+ public String resolveAuthPasswordAttempt(ClientSession session)
+ throws Exception {
+ String[] results = interactive(session, null, null, "", //$NON-NLS-1$
+ new String[] { SshdText.get().passwordPrompt },
+ new boolean[] { false });
+ return (results == null || results.length == 0) ? null : results[0];
+ }
+
+ @Override
+ public String getUpdatedPassword(ClientSession session, String prompt,
+ String lang) {
+ // TODO Implement password update in password authentication?
+ return null;
+ }
+
+ /**
+ * Creates a {@link URIish} from the given remote address and user name.
+ *
+ * @param userName
+ * for the uri
+ * @param remote
+ * address of the remote host
+ * @return the uri, with {@link SshConstants#SSH_SCHEME} as scheme
+ */
+ public static URIish toURI(String userName, InetSocketAddress remote) {
+ String host = remote.getHostString();
+ int port = remote.getPort();
+ return new URIish() //
+ .setScheme(SshConstants.SSH_SCHEME) //
+ .setHost(host) //
+ .setPort(port) //
+ .setUser(userName);
+ }
+
+ /**
+ * A {@link SessionListener} that removes itself from the session when
+ * authentication is done or the session is closed.
+ */
+ private static class SessionAuthMarker implements SessionListener {
+
+ private final Map<Session, SessionListener> registered;
+
+ public SessionAuthMarker(Map<Session, SessionListener> registered) {
+ this.registered = registered;
+ }
+
+ @Override
+ public void sessionEvent(Session session, SessionListener.Event event) {
+ if (event == SessionListener.Event.Authenticated) {
+ session.removeSessionListener(this);
+ registered.remove(session, this);
+ }
+ }
+
+ @Override
+ public void sessionClosed(Session session) {
+ session.removeSessionListener(this);
+ registered.remove(session, this);
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java
new file mode 100644
index 0000000000..6b2345df1b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/KnownHostEntryReader.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.text.MessageFormat.format;
+import static org.apache.sshd.client.config.hosts.HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM;
+import static org.apache.sshd.client.config.hosts.HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.sshd.client.config.hosts.HostPatternValue;
+import org.apache.sshd.client.config.hosts.HostPatternsHolder;
+import org.apache.sshd.client.config.hosts.KnownHostEntry;
+import org.apache.sshd.client.config.hosts.KnownHostHashValue;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Apache MINA sshd 2.0.0 KnownHostEntry cannot read a host entry line like
+ * "host:port ssh-rsa &lt;key&gt;"; it complains about an illegal character in
+ * the host name (correct would be "[host]:port"). The default known_hosts
+ * reader also aborts reading on the first error.
+ * <p>
+ * This reader is a bit more robust and tries to handle this case if there is
+ * only one colon (otherwise it might be an IPv6 address (without port)), and it
+ * skips and logs invalid entries, but still returns all other valid entries
+ * from the file.
+ * </p>
+ */
+public class KnownHostEntryReader {
+
+ private static final Logger LOG = LoggerFactory
+ .getLogger(KnownHostEntryReader.class);
+
+ private KnownHostEntryReader() {
+ // No instantiation
+ }
+
+ /**
+ * Reads a known_hosts file and returns all valid entries. Invalid entries
+ * are skipped (and a message is logged).
+ *
+ * @param path
+ * of the file to read
+ * @return a {@link List} of all valid entries read from the file
+ * @throws IOException
+ * if the file cannot be read.
+ */
+ public static List<KnownHostEntry> readFromFile(Path path)
+ throws IOException {
+ List<KnownHostEntry> result = new ArrayList<>();
+ try (BufferedReader r = Files.newBufferedReader(path, UTF_8)) {
+ r.lines().forEachOrdered(l -> {
+ if (l == null) {
+ return;
+ }
+ String line = clean(l);
+ if (line.isEmpty()) {
+ return;
+ }
+ try {
+ KnownHostEntry entry = parseHostEntry(line);
+ if (entry != null) {
+ result.add(entry);
+ } else {
+ LOG.warn(format(SshdText.get().knownHostsInvalidLine,
+ path, line));
+ }
+ } catch (RuntimeException e) {
+ LOG.warn(format(SshdText.get().knownHostsInvalidLine, path,
+ line), e);
+ }
+ });
+ }
+ return result;
+ }
+
+ private static String clean(String line) {
+ int i = line.indexOf('#');
+ return i < 0 ? line.trim() : line.substring(0, i).trim();
+ }
+
+ static KnownHostEntry parseHostEntry(String line) {
+ KnownHostEntry entry = new KnownHostEntry();
+ entry.setConfigLine(line);
+ String tmp = line;
+ int i = 0;
+ if (tmp.charAt(0) == KnownHostEntry.MARKER_INDICATOR) {
+ // A marker
+ i = tmp.indexOf(' ', 1);
+ if (i < 0) {
+ return null;
+ }
+ entry.setMarker(tmp.substring(1, i));
+ tmp = tmp.substring(i + 1).trim();
+ }
+ i = tmp.indexOf(' ');
+ if (i < 0) {
+ return null;
+ }
+ // Hash, or host patterns
+ if (tmp.charAt(0) == KnownHostHashValue.HASHED_HOST_DELIMITER) {
+ // Hashed host entry
+ KnownHostHashValue hash = KnownHostHashValue
+ .parse(tmp.substring(0, i));
+ if (hash == null) {
+ return null;
+ }
+ entry.setHashedEntry(hash);
+ entry.setPatterns(null);
+ } else {
+ Collection<HostPatternValue> patterns = parsePatterns(
+ tmp.substring(0, i));
+ if (patterns == null || patterns.isEmpty()) {
+ return null;
+ }
+ entry.setHashedEntry(null);
+ entry.setPatterns(patterns);
+ }
+ tmp = tmp.substring(i + 1).trim();
+ AuthorizedKeyEntry key = PublicKeyEntry
+ .parsePublicKeyEntry(new AuthorizedKeyEntry(), tmp);
+ if (key == null) {
+ return null;
+ }
+ entry.setKeyEntry(key);
+ return entry;
+ }
+
+ private static Collection<HostPatternValue> parsePatterns(String text) {
+ if (text.isEmpty()) {
+ return null;
+ }
+ List<String> items = Arrays.stream(text.split(",")) //$NON-NLS-1$
+ .filter(item -> item != null && !item.isEmpty()).map(item -> {
+ if (NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM == item
+ .charAt(0)) {
+ return item;
+ }
+ int firstColon = item.indexOf(':');
+ if (firstColon < 0) {
+ return item;
+ }
+ int secondColon = item.indexOf(':', firstColon + 1);
+ if (secondColon > 0) {
+ // Assume an IPv6 address (without port).
+ return item;
+ }
+ // We have "host:port", should be "[host]:port"
+ return NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM
+ + item.substring(0, firstColon)
+ + NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM
+ + item.substring(firstColon);
+ }).collect(Collectors.toList());
+ return items.isEmpty() ? null : HostPatternsHolder.parsePatterns(items);
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java
new file mode 100644
index 0000000000..acb77c5bb7
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/OpenSshServerKeyDatabase.java
@@ -0,0 +1,824 @@
+/*
+ * Copyright (C) 2018, 2025 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;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.text.MessageFormat.format;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+import org.apache.sshd.client.config.hosts.HostPatternsHolder;
+import org.apache.sshd.client.config.hosts.KnownHostDigest;
+import org.apache.sshd.client.config.hosts.KnownHostEntry;
+import org.apache.sshd.client.config.hosts.KnownHostHashValue;
+import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier.HostEntryPair;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.OpenSshCertificate;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.config.keys.UnsupportedSshPublicKey;
+import org.apache.sshd.common.digest.BuiltinDigests;
+import org.apache.sshd.common.mac.Mac;
+import org.apache.sshd.common.util.io.ModifiableFileWatcher;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.storage.file.LockFile;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.sshd.ServerKeyDatabase;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A sever host key verifier that honors the {@code StrictHostKeyChecking} and
+ * {@code UserKnownHostsFile} values from the ssh configuration.
+ * <p>
+ * The verifier can be given default known_hosts files in the constructor, which
+ * will be used if the ssh config does not specify a {@code UserKnownHostsFile}.
+ * If the ssh config <em>does</em> set {@code UserKnownHostsFile}, the verifier
+ * uses the given files in the order given. Non-existing or unreadable files are
+ * ignored.
+ * <p>
+ * {@code StrictHostKeyChecking} accepts the following values:
+ * <dl>
+ * <dt>ask</dt>
+ * <dd>Ask the user whether new or changed keys shall be accepted and be added
+ * to the known_hosts file.</dd>
+ * <dt>yes/true</dt>
+ * <dd>Accept only keys listed in the known_hosts file.</dd>
+ * <dt>no/false</dt>
+ * <dd>Silently accept all new or changed keys, add new keys to the known_hosts
+ * file.</dd>
+ * <dt>accept-new</dt>
+ * <dd>Silently accept keys for new hosts and add them to the known_hosts
+ * file.</dd>
+ * </dl>
+ * <p>
+ * If {@code StrictHostKeyChecking} is not set, or set to any other value, the
+ * default value <b>ask</b> is active.
+ * <p>
+ * This implementation relies on the {@link ClientSession} being a
+ * {@link JGitClientSession}. By default Apache MINA sshd does not forward the
+ * config file host entry to the session, so it would be unknown here which
+ * entry it was and what setting of {@code StrictHostKeyChecking} should be
+ * used. If used with some other session type, the implementation assumes
+ * "<b>ask</b>".
+ * <p>
+ * Asking the user is done via a {@link CredentialsProvider} obtained from the
+ * session. If none is set, the implementation falls back to strict host key
+ * checking ("<b>yes</b>").
+ * <p>
+ * Note that adding a key to the known hosts file may create the file. You can
+ * specify in the constructor whether the user shall be asked about that, too.
+ * If the user declines updating the file, but the key was otherwise
+ * accepted (user confirmed for "<b>ask</b>", or "no" or "accept-new" are
+ * active), the key is accepted for this session only.
+ * <p>
+ * If several known hosts files are specified, a new key is always added to the
+ * first file (even if it doesn't exist yet; see the note about file creation
+ * above).
+ *
+ * @see <a href="http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5">man
+ * ssh-config</a>
+ */
+public class OpenSshServerKeyDatabase
+ implements ServerKeyDatabase {
+
+ // TODO: GlobalKnownHostsFile? May need some kind of LRU caching; these
+ // files may be large!
+
+ private static final Logger LOG = LoggerFactory
+ .getLogger(OpenSshServerKeyDatabase.class);
+
+ /** Can be used to mark revoked known host lines. */
+ private static final String MARKER_REVOKED = "revoked"; //$NON-NLS-1$
+
+ /** Marks CA keys used for SSH certificates. */
+ private static final String MARKER_CA = "cert-authority"; //$NON-NLS-1$
+
+ private final boolean askAboutNewFile;
+
+ private final Map<Path, HostKeyFile> knownHostsFiles = new ConcurrentHashMap<>();
+
+ private final List<HostKeyFile> defaultFiles = new ArrayList<>();
+
+ private Random prng;
+
+ /**
+ * Creates a new {@link OpenSshServerKeyDatabase}.
+ *
+ * @param askAboutNewFile
+ * whether to ask the user, if possible, about creating a new
+ * non-existing known_hosts file
+ * @param defaultFiles
+ * typically ~/.ssh/known_hosts and ~/.ssh/known_hosts2. May be
+ * empty or {@code null}, in which case no default files are
+ * installed. The files need not exist.
+ */
+ public OpenSshServerKeyDatabase(boolean askAboutNewFile,
+ List<Path> defaultFiles) {
+ if (defaultFiles != null) {
+ for (Path file : defaultFiles) {
+ HostKeyFile newFile = new HostKeyFile(file);
+ knownHostsFiles.put(file, newFile);
+ this.defaultFiles.add(newFile);
+ }
+ }
+ this.askAboutNewFile = askAboutNewFile;
+ }
+
+ private List<HostKeyFile> getFilesToUse(@NonNull Configuration config) {
+ List<HostKeyFile> filesToUse = defaultFiles;
+ List<HostKeyFile> userFiles = addUserHostKeyFiles(
+ config.getUserKnownHostsFiles());
+ if (!userFiles.isEmpty()) {
+ filesToUse = userFiles;
+ }
+ return filesToUse;
+ }
+
+ @Override
+ public List<PublicKey> lookup(@NonNull String connectAddress,
+ @NonNull InetSocketAddress remoteAddress,
+ @NonNull Configuration config) {
+ List<HostKeyFile> filesToUse = getFilesToUse(config);
+ List<PublicKey> result = new ArrayList<>();
+ Collection<SshdSocketAddress> candidates = getCandidates(
+ connectAddress, remoteAddress);
+ for (HostKeyFile file : filesToUse) {
+ for (HostEntryPair current : file.get()) {
+ KnownHostEntry entry = current.getHostEntry();
+ if (current.getServerKey() instanceof UnsupportedSshPublicKey) {
+ continue;
+ }
+ if (!isRevoked(entry) && !isCertificateAuthority(entry)) {
+ for (SshdSocketAddress host : candidates) {
+ if (entry.isHostMatch(host.getHostName(),
+ host.getPort())) {
+ result.add(current.getServerKey());
+ break;
+ }
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public boolean accept(@NonNull String connectAddress,
+ @NonNull InetSocketAddress remoteAddress,
+ @NonNull PublicKey serverKey,
+ @NonNull Configuration config, CredentialsProvider provider) {
+ List<HostKeyFile> filesToUse = getFilesToUse(config);
+ AskUser ask = new AskUser(config, provider);
+ HostEntryPair[] modified = { null };
+ Path path = null;
+ Collection<SshdSocketAddress> candidates = getCandidates(connectAddress,
+ remoteAddress);
+ for (HostKeyFile file : filesToUse) {
+ HostEntryPair lastModified = modified[0];
+ try {
+ if (find(candidates, serverKey, file.get(), modified)) {
+ return true;
+ }
+ } catch (RevokedKeyException e) {
+ ask.revokedKey(remoteAddress, serverKey, file.getPath());
+ return false;
+ }
+ if (modified[0] != lastModified) {
+ // Remember the file in which we might need to update the
+ // entry
+ path = file.getPath();
+ }
+ }
+ if (serverKey instanceof OpenSshCertificate) {
+ return false;
+ }
+ if (modified[0] != null) {
+ // We found an entry, but with a different key.
+ AskUser.ModifiedKeyHandling toDo = ask.acceptModifiedServerKey(
+ remoteAddress, modified[0].getServerKey(),
+ serverKey, path);
+ if (toDo == AskUser.ModifiedKeyHandling.ALLOW_AND_STORE) {
+ if (modified[0]
+ .getServerKey() instanceof UnsupportedSshPublicKey) {
+ // Never update a line containing an unknown key type,
+ // always add.
+ addKeyToFile(filesToUse.get(0), candidates, serverKey, ask,
+ config);
+ } else {
+ try {
+ updateModifiedServerKey(serverKey, modified[0], path);
+ knownHostsFiles.get(path).resetReloadAttributes();
+ } catch (IOException e) {
+ LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate,
+ path));
+ }
+ }
+ }
+ if (toDo == AskUser.ModifiedKeyHandling.DENY) {
+ return false;
+ }
+ // TODO: OpenSsh disables password and keyboard-interactive
+ // authentication in this case. Also agent and local port forwarding
+ // are switched off. (Plus a few other things such as X11 forwarding
+ // that are of no interest to a git client.)
+ return true;
+ } else if (ask.acceptUnknownKey(remoteAddress, serverKey)) {
+ if (!filesToUse.isEmpty()) {
+ addKeyToFile(filesToUse.get(0), candidates, serverKey, ask,
+ config);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private static class RevokedKeyException extends Exception {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static boolean isRevoked(KnownHostEntry entry) {
+ return MARKER_REVOKED.equals(entry.getMarker());
+ }
+
+ private static boolean isCertificateAuthority(KnownHostEntry entry) {
+ return MARKER_CA.equals(entry.getMarker());
+ }
+
+ private boolean find(Collection<SshdSocketAddress> candidates,
+ PublicKey serverKey, List<HostEntryPair> entries,
+ HostEntryPair[] modified) throws RevokedKeyException {
+ PublicKey keyToCheck = serverKey;
+ boolean isCert = false;
+ String keyType = KeyUtils.getKeyType(keyToCheck);
+ String modifiedKeyType = null;
+ if (modified[0] != null) {
+ modifiedKeyType = modified[0].getHostEntry().getKeyEntry()
+ .getKeyType();
+ }
+ if (serverKey instanceof OpenSshCertificate) {
+ keyToCheck = ((OpenSshCertificate) serverKey).getCaPubKey();
+ isCert = true;
+ }
+ for (HostEntryPair current : entries) {
+ KnownHostEntry entry = current.getHostEntry();
+ if (candidates.stream().anyMatch(host -> entry
+ .isHostMatch(host.getHostName(), host.getPort()))) {
+ boolean revoked = isRevoked(entry);
+ boolean haveCert = isCertificateAuthority(entry);
+ if (KeyUtils.compareKeys(keyToCheck, current.getServerKey())) {
+ // Exact match
+ if (revoked) {
+ throw new RevokedKeyException();
+ }
+ if (haveCert == isCert) {
+ modified[0] = null;
+ return true;
+ }
+ }
+ if (haveCert == isCert && !haveCert && !revoked) {
+ // Server sent a different key.
+ if (modifiedKeyType == null) {
+ modified[0] = current;
+ modifiedKeyType = entry.getKeyEntry().getKeyType();
+ } else if (!keyType.equals(modifiedKeyType)) {
+ String thisKeyType = entry.getKeyEntry().getKeyType();
+ if (isBetterMatch(keyType, thisKeyType,
+ modifiedKeyType)) {
+ // Since we may replace the modified[0] key,
+ // prefer to report a key of the same key type
+ // as having been modified.
+ modified[0] = current;
+ modifiedKeyType = keyType;
+ }
+ }
+ // Keep going -- maybe there's another entry for this
+ // host
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isBetterMatch(String keyType, String thisType,
+ String modifiedType) {
+ if (keyType.equals(thisType)) {
+ return true;
+ }
+ // EC keys are a bit special because they encode the curve in the key
+ // type. If we have no exactly matching EC key type in known_hosts, we
+ // still prefer to update an existing EC key type over some other key
+ // type.
+ if (!keyType.startsWith("ecdsa") || !thisType.startsWith("ecdsa")) { //$NON-NLS-1$ //$NON-NLS-2$
+ return false;
+ }
+ if (!modifiedType.startsWith("ecdsa")) { //$NON-NLS-1$
+ return true;
+ }
+ // All three are EC keys. thisType doesn't match the size of keyType
+ // (otherwise the two would have compared equal above already), so it is
+ // not better than modifiedType.
+ return false;
+ }
+
+ private List<HostKeyFile> addUserHostKeyFiles(List<String> fileNames) {
+ if (fileNames == null || fileNames.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List<HostKeyFile> userFiles = new ArrayList<>();
+ for (String name : fileNames) {
+ try {
+ Path path = Paths.get(name);
+ HostKeyFile file = knownHostsFiles.computeIfAbsent(path,
+ p -> new HostKeyFile(path));
+ userFiles.add(file);
+ } catch (InvalidPathException e) {
+ LOG.warn(format(SshdText.get().knownHostsInvalidPath,
+ name));
+ }
+ }
+ return userFiles;
+ }
+
+ private void addKeyToFile(HostKeyFile file,
+ Collection<SshdSocketAddress> candidates, PublicKey serverKey,
+ AskUser ask, Configuration config) {
+ Path path = file.getPath();
+ try {
+ if (Files.exists(path) || !askAboutNewFile
+ || ask.createNewFile(path)) {
+ updateKnownHostsFile(candidates, serverKey, path, config);
+ file.resetReloadAttributes();
+ }
+ } catch (Exception e) {
+ LOG.warn(format(SshdText.get().knownHostsCouldNotUpdate, path), e);
+ }
+ }
+
+ private void updateKnownHostsFile(Collection<SshdSocketAddress> candidates,
+ PublicKey serverKey, Path path, Configuration config)
+ throws Exception {
+ String newEntry = createHostKeyLine(candidates, serverKey, config);
+ if (newEntry == null) {
+ return;
+ }
+ LockFile lock = new LockFile(path.toFile());
+ if (lock.lockForAppend()) {
+ try {
+ try (BufferedWriter writer = new BufferedWriter(
+ new OutputStreamWriter(lock.getOutputStream(),
+ UTF_8))) {
+ writer.newLine();
+ writer.write(newEntry);
+ writer.newLine();
+ }
+ lock.commit();
+ } catch (IOException e) {
+ lock.unlock();
+ throw e;
+ }
+ } else {
+ LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate,
+ path));
+ }
+ }
+
+ private void updateModifiedServerKey(PublicKey serverKey,
+ HostEntryPair entry, Path path)
+ throws IOException {
+ KnownHostEntry hostEntry = entry.getHostEntry();
+ String oldLine = hostEntry.getConfigLine();
+ if (oldLine == null) {
+ return;
+ }
+ String newLine = updateHostKeyLine(oldLine, serverKey);
+ if (newLine == null || newLine.isEmpty()) {
+ return;
+ }
+ if (oldLine.isEmpty() || newLine.equals(oldLine)) {
+ // Shouldn't happen.
+ return;
+ }
+ LockFile lock = new LockFile(path.toFile());
+ if (lock.lock()) {
+ try {
+ try (BufferedWriter writer = new BufferedWriter(
+ new OutputStreamWriter(lock.getOutputStream(), UTF_8));
+ BufferedReader reader = Files.newBufferedReader(path,
+ UTF_8)) {
+ boolean done = false;
+ String line;
+ while ((line = reader.readLine()) != null) {
+ String toWrite = line;
+ if (!done) {
+ int pos = line.indexOf('#');
+ String toTest = pos < 0 ? line
+ : line.substring(0, pos);
+ if (toTest.trim().equals(oldLine)) {
+ toWrite = newLine;
+ done = true;
+ }
+ }
+ writer.write(toWrite);
+ writer.newLine();
+ }
+ }
+ lock.commit();
+ } catch (IOException e) {
+ lock.unlock();
+ throw e;
+ }
+ } else {
+ LOG.warn(format(SshdText.get().knownHostsFileLockedUpdate,
+ path));
+ }
+ }
+
+ private static class AskUser {
+
+ public enum ModifiedKeyHandling {
+ DENY, ALLOW, ALLOW_AND_STORE
+ }
+
+ private enum Check {
+ ASK, DENY, ALLOW;
+ }
+
+ private final @NonNull Configuration config;
+
+ private final CredentialsProvider provider;
+
+ public AskUser(@NonNull Configuration config,
+ CredentialsProvider provider) {
+ this.config = config;
+ this.provider = provider;
+ }
+
+ private static boolean askUser(CredentialsProvider provider, URIish uri,
+ String prompt, String... messages) {
+ List<CredentialItem> items = new ArrayList<>(messages.length + 1);
+ for (String message : messages) {
+ items.add(new CredentialItem.InformationalMessage(message));
+ }
+ if (prompt != null) {
+ CredentialItem.YesNoType answer = new CredentialItem.YesNoType(
+ prompt);
+ items.add(answer);
+ return provider.get(uri, items) && answer.getValue();
+ }
+ return provider.get(uri, items);
+ }
+
+ private Check checkMode(SocketAddress remoteAddress, boolean changed) {
+ if (!(remoteAddress instanceof InetSocketAddress)) {
+ return Check.DENY;
+ }
+ switch (config.getStrictHostKeyChecking()) {
+ case REQUIRE_MATCH:
+ return Check.DENY;
+ case ACCEPT_ANY:
+ return Check.ALLOW;
+ case ACCEPT_NEW:
+ return changed ? Check.DENY : Check.ALLOW;
+ default:
+ return provider == null ? Check.DENY : Check.ASK;
+ }
+ }
+
+ public void revokedKey(SocketAddress remoteAddress, PublicKey serverKey,
+ Path path) {
+ if (provider == null) {
+ return;
+ }
+ InetSocketAddress remote = (InetSocketAddress) remoteAddress;
+ boolean isCert = serverKey instanceof OpenSshCertificate;
+ PublicKey keyToReport = isCert
+ ? ((OpenSshCertificate) serverKey).getCaPubKey()
+ : serverKey;
+ URIish uri = JGitUserInteraction.toURI(config.getUsername(),
+ remote);
+ String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256,
+ keyToReport);
+ String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5,
+ keyToReport);
+ String keyAlgorithm = keyToReport.getAlgorithm();
+ String msg = isCert
+ ? SshdText.get().knownHostsRevokedCertificateMsg
+ : SshdText.get().knownHostsRevokedKeyMsg;
+ askUser(provider, uri, null, //
+ format(msg, remote.getHostString(), path),
+ format(SshdText.get().knownHostsKeyFingerprints,
+ keyAlgorithm),
+ md5, sha256);
+ }
+
+ public boolean acceptUnknownKey(SocketAddress remoteAddress,
+ PublicKey serverKey) {
+ Check check = checkMode(remoteAddress, false);
+ if (check != Check.ASK) {
+ return check == Check.ALLOW;
+ }
+ InetSocketAddress remote = (InetSocketAddress) remoteAddress;
+ // Ask the user
+ String sha256 = KeyUtils.getFingerPrint(BuiltinDigests.sha256,
+ serverKey);
+ String md5 = KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey);
+ String keyAlgorithm = serverKey.getAlgorithm();
+ String remoteHost = remote.getHostString();
+ URIish uri = JGitUserInteraction.toURI(config.getUsername(),
+ remote);
+ String prompt = SshdText.get().knownHostsUnknownKeyPrompt;
+ return askUser(provider, uri, prompt, //
+ format(SshdText.get().knownHostsUnknownKeyMsg,
+ remoteHost),
+ format(SshdText.get().knownHostsKeyFingerprints,
+ keyAlgorithm),
+ md5, sha256);
+ }
+
+ public ModifiedKeyHandling acceptModifiedServerKey(
+ InetSocketAddress remoteAddress, PublicKey expected,
+ PublicKey actual, Path path) {
+ Check check = checkMode(remoteAddress, true);
+ if (check == Check.ALLOW) {
+ // Never auto-store on CHECK.ALLOW
+ return ModifiedKeyHandling.ALLOW;
+ }
+ String keyAlgorithm = actual.getAlgorithm();
+ String remoteHost = remoteAddress.getHostString();
+ URIish uri = JGitUserInteraction.toURI(config.getUsername(),
+ remoteAddress);
+ List<String> messages = new ArrayList<>();
+ String warning = format(
+ SshdText.get().knownHostsModifiedKeyWarning,
+ keyAlgorithm, expected.getAlgorithm(), remoteHost,
+ KeyUtils.getFingerPrint(BuiltinDigests.md5, expected),
+ KeyUtils.getFingerPrint(BuiltinDigests.sha256, expected),
+ KeyUtils.getFingerPrint(BuiltinDigests.md5, actual),
+ KeyUtils.getFingerPrint(BuiltinDigests.sha256, actual));
+ messages.addAll(Arrays.asList(warning.split("\n"))); //$NON-NLS-1$
+
+ if (check == Check.DENY) {
+ if (provider != null) {
+ messages.add(format(
+ SshdText.get().knownHostsModifiedKeyDenyMsg, path));
+ askUser(provider, uri, null,
+ messages.toArray(new String[0]));
+ }
+ return ModifiedKeyHandling.DENY;
+ }
+ // ASK -- two questions: procceed? and store?
+ List<CredentialItem> items = new ArrayList<>(messages.size() + 2);
+ for (String message : messages) {
+ items.add(new CredentialItem.InformationalMessage(message));
+ }
+ CredentialItem.YesNoType proceed = new CredentialItem.YesNoType(
+ SshdText.get().knownHostsModifiedKeyAcceptPrompt);
+ CredentialItem.YesNoType store = new CredentialItem.YesNoType(
+ SshdText.get().knownHostsModifiedKeyStorePrompt);
+ items.add(proceed);
+ items.add(store);
+ if (provider.get(uri, items) && proceed.getValue()) {
+ return store.getValue() ? ModifiedKeyHandling.ALLOW_AND_STORE
+ : ModifiedKeyHandling.ALLOW;
+ }
+ return ModifiedKeyHandling.DENY;
+ }
+
+ public boolean createNewFile(Path path) {
+ if (provider == null) {
+ // We can't ask, so don't create the file
+ return false;
+ }
+ URIish uri = new URIish().setPath(path.toString());
+ return askUser(provider, uri, //
+ format(SshdText.get().knownHostsUserAskCreationPrompt,
+ path), //
+ format(SshdText.get().knownHostsUserAskCreationMsg, path));
+ }
+ }
+
+ private static class HostKeyFile extends ModifiableFileWatcher
+ implements Supplier<List<HostEntryPair>> {
+
+ private List<HostEntryPair> entries = Collections.emptyList();
+
+ public HostKeyFile(Path path) {
+ super(path);
+ }
+
+ @Override
+ public List<HostEntryPair> get() {
+ Path path = getPath();
+ synchronized (this) {
+ try {
+ if (checkReloadRequired()) {
+ entries = reload(getPath());
+ }
+ } catch (IOException e) {
+ LOG.warn(format(SshdText.get().knownHostsFileReadFailed,
+ path));
+ }
+ return Collections.unmodifiableList(entries);
+ }
+ }
+
+ private List<HostEntryPair> reload(Path path) throws IOException {
+ try {
+ List<KnownHostEntry> rawEntries = KnownHostEntryReader
+ .readFromFile(path);
+ updateReloadAttributes();
+ if (rawEntries == null || rawEntries.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List<HostEntryPair> newEntries = new ArrayList<>();
+ for (KnownHostEntry entry : rawEntries) {
+ AuthorizedKeyEntry keyPart = entry.getKeyEntry();
+ if (keyPart == null) {
+ continue;
+ }
+ try {
+ PublicKey serverKey = keyPart.resolvePublicKey(null,
+ PublicKeyEntryResolver.UNSUPPORTED);
+ if (serverKey == null) {
+ LOG.warn(format(
+ SshdText.get().knownHostsUnknownKeyType,
+ path, entry.getConfigLine()));
+ } else {
+ newEntries.add(new HostEntryPair(entry, serverKey));
+ }
+ } catch (GeneralSecurityException e) {
+ LOG.warn(format(SshdText.get().knownHostsInvalidLine,
+ path, entry.getConfigLine()));
+ }
+ }
+ return newEntries;
+ } catch (FileNotFoundException | NoSuchFileException e) {
+ resetReloadAttributes();
+ return Collections.emptyList();
+ }
+ }
+ }
+
+ private int parsePort(String s) {
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ private SshdSocketAddress toSshdSocketAddress(@NonNull String address) {
+ String host = null;
+ int port = SshConstants.DEFAULT_PORT;
+ if (HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM == address
+ .charAt(0)) {
+ int end = address.indexOf(
+ HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM);
+ if (end <= 1) {
+ return null; // Invalid
+ }
+ host = address.substring(1, end);
+ if (end < address.length() - 1
+ && HostPatternsHolder.PORT_VALUE_DELIMITER == address
+ .charAt(end + 1)) {
+ port = parsePort(address.substring(end + 2));
+ }
+ } else {
+ int i = address
+ .lastIndexOf(HostPatternsHolder.PORT_VALUE_DELIMITER);
+ if (i > 0) {
+ port = parsePort(address.substring(i + 1));
+ host = address.substring(0, i);
+ } else {
+ host = address;
+ }
+ }
+ if (port < 0 || port > 65535) {
+ return null;
+ }
+ return new SshdSocketAddress(host, port);
+ }
+
+ private Collection<SshdSocketAddress> getCandidates(
+ @NonNull String connectAddress,
+ @NonNull InetSocketAddress remoteAddress) {
+ Collection<SshdSocketAddress> candidates = new TreeSet<>(
+ SshdSocketAddress.BY_HOST_AND_PORT);
+ candidates.add(SshdSocketAddress.toSshdSocketAddress(remoteAddress));
+ SshdSocketAddress address = toSshdSocketAddress(connectAddress);
+ if (address != null) {
+ candidates.add(address);
+ }
+ List<SshdSocketAddress> result = new ArrayList<>();
+ result.addAll(candidates);
+ if (!remoteAddress.isUnresolved()) {
+ SshdSocketAddress ip = new SshdSocketAddress(
+ remoteAddress.getAddress().getHostAddress(),
+ remoteAddress.getPort());
+ if (candidates.add(ip)) {
+ result.add(ip);
+ }
+ }
+ return result;
+ }
+
+ private String createHostKeyLine(Collection<SshdSocketAddress> patterns,
+ PublicKey key, Configuration config) throws Exception {
+ StringBuilder result = new StringBuilder();
+ Set<String> knownNames = new HashSet<>();
+ if (config.getHashKnownHosts()) {
+ // SHA1 is the only algorithm for host name hashing known to OpenSSH
+ // or to Apache MINA sshd.
+ NamedFactory<Mac> digester = KnownHostDigest.SHA1;
+ Mac mac = digester.create();
+ if (prng == null) {
+ prng = new SecureRandom();
+ }
+ byte[] salt = new byte[mac.getDefaultBlockSize()];
+ // For hashed hostnames, only one hashed pattern is allowed per
+ // https://man.openbsd.org/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT
+ if (!patterns.isEmpty()) {
+ SshdSocketAddress address = patterns.iterator().next();
+ prng.nextBytes(salt);
+ KnownHostHashValue.append(result, digester, salt,
+ KnownHostHashValue.calculateHashValue(
+ address.getHostName(), address.getPort(), mac,
+ salt));
+ }
+ } else {
+ for (SshdSocketAddress address : patterns) {
+ String tgt = address.getHostName() + ':' + address.getPort();
+ if (!knownNames.add(tgt)) {
+ continue;
+ }
+ if (result.length() > 0) {
+ result.append(',');
+ }
+ KnownHostHashValue.appendHostPattern(result,
+ address.getHostName(), address.getPort());
+ }
+ }
+ result.append(' ');
+ PublicKeyEntry.appendPublicKeyEntry(result, key);
+ return result.toString();
+ }
+
+ private String updateHostKeyLine(String line, PublicKey newKey)
+ throws IOException {
+ // Replaces an existing public key by the new key
+ int pos = line.indexOf(' ');
+ if (pos > 0 && line.charAt(0) == KnownHostEntry.MARKER_INDICATOR) {
+ // We're at the end of the marker. Skip ahead to the next blank.
+ pos = line.indexOf(' ', pos + 1);
+ }
+ if (pos < 0) {
+ // Don't update if bogus format
+ return null;
+ }
+ StringBuilder result = new StringBuilder(line.substring(0, pos + 1));
+ PublicKeyEntry.appendPublicKeyEntry(result, newKey);
+ return result.toString();
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java
new file mode 100644
index 0000000000..900c9fba24
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/PasswordProviderWrapper.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2018, 2024 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;
+
+import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import org.apache.sshd.common.AttributeRepository.AttributeKey;
+import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.apache.sshd.common.session.SessionContext;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.sshd.KeyPasswordProvider;
+
+/**
+ * A bridge from sshd's {@link FilePasswordProvider} to our per-session
+ * {@link KeyPasswordProvider} API.
+ */
+public class PasswordProviderWrapper implements FilePasswordProvider {
+
+ private static final AttributeKey<PerSessionState> STATE = new AttributeKey<>();
+
+ private static class PerSessionState {
+
+ Map<String, AtomicInteger> counts = new ConcurrentHashMap<>();
+
+ KeyPasswordProvider delegate;
+
+ }
+
+ private final Supplier<KeyPasswordProvider> factory;
+
+ private PerSessionState noSessionState;
+
+ /**
+ * Creates a new {@link PasswordProviderWrapper}.
+ *
+ * @param factory
+ * to use to create per-session {@link KeyPasswordProvider}s
+ */
+ public PasswordProviderWrapper(
+ @NonNull Supplier<KeyPasswordProvider> factory) {
+ this.factory = factory;
+ }
+
+ private PerSessionState getState(SessionContext context) {
+ PerSessionState state = context != null ? context.getAttribute(STATE)
+ : noSessionState;
+ if (state == null) {
+ state = new PerSessionState();
+ state.delegate = factory.get();
+ state.delegate.setAttempts(
+ PASSWORD_PROMPTS.getRequiredDefault().intValue());
+ if (context != null) {
+ context.setAttribute(STATE, state);
+ } else {
+ noSessionState = state;
+ }
+ }
+ return state;
+ }
+
+ @Override
+ public String getPassword(SessionContext session, NamedResource resource,
+ int attemptIndex) throws IOException {
+ String key = resource.getName();
+ PerSessionState state = getState(session);
+ int attempt = state.counts
+ .computeIfAbsent(key, k -> new AtomicInteger()).get();
+ char[] passphrase = state.delegate.getPassphrase(toUri(key), attempt);
+ if (passphrase == null) {
+ return null;
+ }
+ try {
+ return new String(passphrase);
+ } finally {
+ Arrays.fill(passphrase, '\000');
+ }
+ }
+
+ @Override
+ public ResourceDecodeResult handleDecodeAttemptResult(
+ SessionContext session, NamedResource resource, int retryIndex,
+ String password, Exception err)
+ throws IOException, GeneralSecurityException {
+ String key = resource.getName();
+ PerSessionState state = getState(session);
+ AtomicInteger count = state.counts.get(key);
+ int numberOfAttempts = count == null ? 0 : count.incrementAndGet();
+ ResourceDecodeResult result = null;
+ try {
+ if (state.delegate.keyLoaded(toUri(key), numberOfAttempts, err)) {
+ result = ResourceDecodeResult.RETRY;
+ } else {
+ result = ResourceDecodeResult.TERMINATE;
+ }
+ } finally {
+ if (result != ResourceDecodeResult.RETRY) {
+ state.counts.remove(key);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Creates a {@link URIish} from a given string. The
+ * {@link CredentialsProvider} uses uris as resource identifications.
+ *
+ * @param resourceKey
+ * to convert
+ * @return the uri
+ */
+ private URIish toUri(String resourceKey) {
+ try {
+ return new URIish(resourceKey);
+ } catch (URISyntaxException e) {
+ return new URIish().setPath(resourceKey); // Doesn't check!!
+ }
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/ServerKeyLookup.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/ServerKeyLookup.java
new file mode 100644
index 0000000000..4adea6e84b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/ServerKeyLookup.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2018, 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;
+
+import java.net.SocketAddress;
+import java.security.PublicKey;
+import java.util.List;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.eclipse.jgit.annotations.NonNull;
+
+/**
+ * Offers operations to retrieve server keys from known_hosts files.
+ */
+public interface ServerKeyLookup {
+
+ /**
+ * Retrieves all public keys known for a given remote.
+ *
+ * @param session
+ * needed to determine the config files if specified in the ssh
+ * config
+ * @param remote
+ * to find entries for
+ * @return a possibly empty list of entries found, including revoked ones
+ */
+ @NonNull
+ List<PublicKey> lookup(ClientSession session, SocketAddress remote);
+}
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
new file mode 100644
index 0000000000..e40137870b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright (C) 2018, 2024 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;
+
+import org.eclipse.jgit.nls.NLS;
+import org.eclipse.jgit.nls.TranslationBundle;
+
+/**
+ * Externalized text messages for localization.
+ */
+@SuppressWarnings("MissingSummary")
+public final class SshdText extends TranslationBundle {
+
+ /**
+ * Get an instance of this translation bundle.
+ *
+ * @return an instance of this translation bundle
+ */
+ public static SshdText get() {
+ return NLS.getBundleFor(SshdText.class);
+ }
+
+ // @formatter:off
+ /***/ public String authenticationCanceled;
+ /***/ public String authenticationOnClosedSession;
+ /***/ public String authGssApiAttempt;
+ /***/ public String authGssApiExhausted;
+ /***/ public String authGssApiFailure;
+ /***/ public String authGssApiNotTried;
+ /***/ public String authGssApiPartialSuccess;
+ /***/ public String authPasswordAttempt;
+ /***/ public String authPasswordChangeAttempt;
+ /***/ public String authPasswordExhausted;
+ /***/ public String authPasswordFailure;
+ /***/ public String authPasswordNotTried;
+ /***/ public String authPasswordPartialSuccess;
+ /***/ public String authPubkeyAttempt;
+ /***/ public String authPubkeyAttemptAgent;
+ /***/ public String authPubkeyExhausted;
+ /***/ public String authPubkeyFailure;
+ /***/ public String authPubkeyNoKeys;
+ /***/ public String authPubkeyPartialSuccess;
+ /***/ public String closeListenerFailed;
+ /***/ public String cannotReadPublicKey;
+ /***/ public String configInvalidPath;
+ /***/ public String configInvalidPattern;
+ /***/ public String configInvalidPositive;
+ /***/ public String configInvalidProxyJump;
+ /***/ public String configNoKnownAlgorithms;
+ /***/ public String configProxyJumpNotSsh;
+ /***/ public String configProxyJumpWithPath;
+ /***/ public String configUnknownAlgorithm;
+ /***/ public String ftpCloseFailed;
+ /***/ public String gssapiFailure;
+ /***/ public String gssapiInitFailure;
+ /***/ public String gssapiUnexpectedMechanism;
+ /***/ public String gssapiUnexpectedMessage;
+ /***/ public String identityFileCannotDecrypt;
+ /***/ public String identityFileNoKey;
+ /***/ public String identityFileMultipleKeys;
+ /***/ public String identityFileNotFound;
+ /***/ public String identityFileUnsupportedFormat;
+ /***/ public String invalidSignatureAlgorithm;
+ /***/ public String kexServerKeyInvalid;
+ /***/ public String keyEncryptedMsg;
+ /***/ public String keyEncryptedPrompt;
+ /***/ public String keyEncryptedRetry;
+ /***/ public String keyLoadFailed;
+ /***/ public String knownHostsCouldNotUpdate;
+ /***/ public String knownHostsFileLockedUpdate;
+ /***/ public String knownHostsFileReadFailed;
+ /***/ public String knownHostsInvalidLine;
+ /***/ public String knownHostsInvalidPath;
+ /***/ public String knownHostsKeyFingerprints;
+ /***/ public String knownHostsModifiedKeyAcceptPrompt;
+ /***/ public String knownHostsModifiedKeyDenyMsg;
+ /***/ public String knownHostsModifiedKeyStorePrompt;
+ /***/ public String knownHostsModifiedKeyWarning;
+ /***/ public String knownHostsRevokedCertificateMsg;
+ /***/ public String knownHostsRevokedKeyMsg;
+ /***/ public String knownHostsUnknownKeyMsg;
+ /***/ public String knownHostsUnknownKeyPrompt;
+ /***/ public String knownHostsUnknownKeyType;
+ /***/ public String knownHostsUserAskCreationMsg;
+ /***/ 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;
+ /***/ public String proxyHttpUnexpectedReply;
+ /***/ public String proxyHttpUnspecifiedFailureReason;
+ /***/ public String proxyJumpAbort;
+ /***/ public String proxyPasswordPrompt;
+ /***/ public String proxySocksAuthenticationFailed;
+ /***/ public String proxySocksFailureForbidden;
+ /***/ public String proxySocksFailureGeneral;
+ /***/ public String proxySocksFailureHostUnreachable;
+ /***/ public String proxySocksFailureNetworkUnreachable;
+ /***/ public String proxySocksFailureRefused;
+ /***/ public String proxySocksFailureTTL;
+ /***/ public String proxySocksFailureUnspecified;
+ /***/ public String proxySocksFailureUnsupportedAddress;
+ /***/ public String proxySocksFailureUnsupportedCommand;
+ /***/ public String proxySocksGssApiFailure;
+ /***/ public String proxySocksGssApiMessageTooShort;
+ /***/ public String proxySocksGssApiUnknownMessage;
+ /***/ public String proxySocksGssApiVersionMismatch;
+ /***/ public String proxySocksNoRemoteHostName;
+ /***/ public String proxySocksPasswordTooLong;
+ /***/ public String proxySocksUnexpectedMessage;
+ /***/ public String proxySocksUnexpectedVersion;
+ /***/ public String proxySocksUsernameTooLong;
+ /***/ public String pubkeyAuthAddKeyToAgentError;
+ /***/ public String pubkeyAuthAddKeyToAgentQuestion;
+ /***/ public String pubkeyAuthWrongCommand;
+ /***/ public String pubkeyAuthWrongKey;
+ /***/ public String pubkeyAuthWrongSignatureAlgorithm;
+ /***/ public String serverIdNotReceived;
+ /***/ public String serverIdTooLong;
+ /***/ public String serverIdWithNul;
+ /***/ public String sessionCloseFailed;
+ /***/ public String sessionWithoutUsername;
+ /***/ public String sshAgentEdDSAFormatError;
+ /***/ public String sshAgentPayloadLengthError;
+ /***/ public String sshAgentReplyLengthError;
+ /***/ public String sshAgentReplyUnexpected;
+ /***/ public String sshAgentShortReadBuffer;
+ /***/ public String sshAgentUnknownKey;
+ /***/ public String sshAgentWrongKeyLength;
+ /***/ public String sshAgentWrongNumberOfKeys;
+ /***/ public String sshClosingDown;
+ /***/ public String sshCommandTimeout;
+ /***/ public String sshProcessStillRunning;
+ /***/ public String sshProxySessionCloseFailed;
+ /***/ public String signAllowedSignersCertAuthorityError;
+ /***/ public String signAllowedSignersEmptyIdentity;
+ /***/ public String signAllowedSignersEmptyNamespaces;
+ /***/ public String signAllowedSignersFormatError;
+ /***/ public String signAllowedSignersInvalidDate;
+ /***/ public String signAllowedSignersLineFormat;
+ /***/ public String signAllowedSignersMultiple;
+ /***/ public String signAllowedSignersNoIdentities;
+ /***/ public String signAllowedSignersPublicKeyParsing;
+ /***/ public String signAllowedSignersUnterminatedQuote;
+ /***/ public String signCertAlgorithmMismatch;
+ /***/ public String signCertAlgorithmUnknown;
+ /***/ public String signCertificateExpired;
+ /***/ public String signCertificateInvalid;
+ /***/ public String signCertificateNotForName;
+ /***/ public String signCertificateRevoked;
+ /***/ public String signCertificateTooEarly;
+ /***/ public String signCertificateWithoutPrincipals;
+ /***/ public String signDefaultKeyEmpty;
+ /***/ public String signDefaultKeyFailed;
+ /***/ public String signDefaultKeyInterrupted;
+ /***/ public String signGarbageAtEnd;
+ /***/ public String signInvalidAlgorithm;
+ /***/ public String signInvalidKeyDSA;
+ /***/ public String signInvalidMagic;
+ /***/ public String signInvalidNamespace;
+ /***/ public String signInvalidSignature;
+ /***/ public String signInvalidVersion;
+ /***/ public String signKeyExpired;
+ /***/ public String signKeyRevoked;
+ /***/ public String signKeyTooEarly;
+ /***/ public String signKrlBlobLeftover;
+ /***/ public String signKrlBlobLengthInvalid;
+ /***/ public String signKrlBlobLengthInvalidExpected;
+ /***/ public String signKrlCaKeyLengthInvalid;
+ /***/ public String signKrlCertificateLeftover;
+ /***/ public String signKrlCertificateSubsectionLeftover;
+ /***/ public String signKrlCertificateSubsectionLength;
+ /***/ public String signKrlEmptyRange;
+ /***/ public String signKrlInvalidBitSetLength;
+ /***/ public String signKrlInvalidKeyIdLength;
+ /***/ public String signKrlInvalidMagic;
+ /***/ public String signKrlInvalidReservedLength;
+ /***/ public String signKrlInvalidVersion;
+ /***/ public String signKrlNoCertificateSubsection;
+ /***/ public String signKrlSerialZero;
+ /***/ public String signKrlShortRange;
+ /***/ public String signKrlUnknownSection;
+ /***/ public String signKrlUnknownSubsection;
+ /***/ public String signLogFailure;
+ /***/ public String signMismatchedSignatureAlgorithm;
+ /***/ public String signNoAgent;
+ /***/ public String signNoPrincipalMatched;
+ /***/ public String signNoPublicKey;
+ /***/ public String signNoSigningKey;
+ /***/ public String signNotUserCertificate;
+ /***/ public String signPublicKeyError;
+ /***/ public String signSeeLog;
+ /***/ public String signSignatureError;
+ /***/ public String signStderr;
+ /***/ public String signTooManyPrivateKeys;
+ /***/ public String signTooManyPublicKeys;
+ /***/ public String signUnknownHashAlgorithm;
+ /***/ public String signUnknownSignatureAlgorithm;
+ /***/ public String signWrongNamespace;
+ /***/ public String unknownProxyProtocol;
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/ConnectorFactoryProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/ConnectorFactoryProvider.java
new file mode 100644
index 0000000000..aba7a76459
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/ConnectorFactoryProvider.java
@@ -0,0 +1,61 @@
+/*
+ * 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.util.Iterator;
+import java.util.ServiceLoader;
+
+import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory;
+
+/**
+ * Provides a {@link ConnectorFactory} obtained via the {@link ServiceLoader}.
+ */
+public final class ConnectorFactoryProvider {
+
+ private static volatile ConnectorFactory INSTANCE = loadDefaultFactory();
+
+ private static ConnectorFactory loadDefaultFactory() {
+ ServiceLoader<ConnectorFactory> loader = ServiceLoader
+ .load(ConnectorFactory.class);
+ Iterator<ConnectorFactory> iter = loader.iterator();
+ while (iter.hasNext()) {
+ ConnectorFactory candidate = iter.next();
+ if (candidate.isSupported()) {
+ return candidate;
+ }
+ }
+ return null;
+
+ }
+
+ /**
+ * Retrieves the currently set default {@link ConnectorFactory}.
+ *
+ * @return the {@link ConnectorFactory}, or {@code null} if none.
+ */
+ public static ConnectorFactory getDefaultFactory() {
+ return INSTANCE;
+ }
+
+ /**
+ * Sets the default {@link ConnectorFactory}.
+ *
+ * @param factory
+ * {@link ConnectorFactory} to use, or {@code null} to use the
+ * factory discovered via the {@link ServiceLoader}.
+ */
+ public static void setDefaultFactory(ConnectorFactory factory) {
+ INSTANCE = factory == null ? loadDefaultFactory() : factory;
+ }
+
+ private ConnectorFactoryProvider() {
+ // No instantiation
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java
new file mode 100644
index 0000000000..a0ffd540f2
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/JGitSshAgentFactory.java
@@ -0,0 +1,82 @@
+/*
+ * 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.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.sshd.agent.SshAgent;
+import org.apache.sshd.agent.SshAgentFactory;
+import org.apache.sshd.agent.SshAgentServer;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.common.FactoryManager;
+import org.apache.sshd.common.channel.ChannelFactory;
+import org.apache.sshd.common.session.ConnectionService;
+import org.apache.sshd.common.session.Session;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.JGitClientSession;
+import org.eclipse.jgit.transport.SshConstants;
+import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory;
+
+/**
+ * A factory for creating {@link SshAgentClient}s.
+ */
+public class JGitSshAgentFactory implements SshAgentFactory {
+
+ private final @NonNull ConnectorFactory factory;
+
+ private final File homeDir;
+
+ /**
+ * Creates a new {@link JGitSshAgentFactory}.
+ *
+ * @param factory
+ * {@link JGitSshAgentFactory} to wrap
+ * @param homeDir
+ * for obtaining the current local user's home directory
+ */
+ public JGitSshAgentFactory(@NonNull ConnectorFactory factory,
+ File homeDir) {
+ this.factory = factory;
+ this.homeDir = homeDir;
+ }
+
+ @Override
+ public List<ChannelFactory> getChannelForwardingFactories(
+ FactoryManager manager) {
+ // No agent forwarding supported.
+ return Collections.emptyList();
+ }
+
+ @Override
+ public SshAgent createClient(Session session, FactoryManager manager)
+ throws IOException {
+ String identityAgent = null;
+ if (session instanceof JGitClientSession) {
+ HostConfigEntry hostConfig = ((JGitClientSession) session)
+ .getHostConfigEntry();
+ identityAgent = hostConfig.getProperty(SshConstants.IDENTITY_AGENT,
+ null);
+ }
+ if (SshConstants.NONE.equals(identityAgent)) {
+ return null;
+ }
+ return new SshAgentClient(factory.create(identityAgent, homeDir));
+ }
+
+ @Override
+ public SshAgentServer createServer(ConnectionService service)
+ throws IOException {
+ // This should be called in a server only.
+ return null;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java
new file mode 100644
index 0000000000..4969414c59
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/agent/SshAgentClient.java
@@ -0,0 +1,475 @@
+/*
+ * 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();
+ if (keyLength <= 0 || keyLength > buffer.available()) {
+ throw new BufferException(
+ MessageFormat.format(SshdText.get().sshAgentWrongKeyLength,
+ Integer.toString(keyLength),
+ Integer.toString(buffer.rpos()),
+ Integer.toString(endOfBuffer)));
+ }
+ int afterKey = buffer.rpos() + keyLength;
+ // 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();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java
new file mode 100644
index 0000000000..ccfcdd8ba8
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AbstractAuthenticationHandler.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2018, 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.auth;
+
+import java.net.InetSocketAddress;
+
+/**
+ * Abstract base class for {@link AuthenticationHandler}s encapsulating basic
+ * common things.
+ *
+ * @param <ParameterType>
+ * defining the parameter type for the authentication
+ * @param <TokenType>
+ * defining the token type for the authentication
+ */
+public abstract class AbstractAuthenticationHandler<ParameterType, TokenType>
+ implements AuthenticationHandler<ParameterType, TokenType> {
+
+ /** The {@link InetSocketAddress} or the proxy to connect to. */
+ protected InetSocketAddress proxy;
+
+ /** The last set parameters. */
+ protected ParameterType params;
+
+ /** A flag telling whether this authentication is done. */
+ protected boolean done;
+
+ /**
+ * Creates a new {@link AbstractAuthenticationHandler} to authenticate with
+ * the given {@code proxy}.
+ *
+ * @param proxy
+ * the {@link InetSocketAddress} of the proxy to connect to
+ */
+ public AbstractAuthenticationHandler(InetSocketAddress proxy) {
+ this.proxy = proxy;
+ }
+
+ @Override
+ public final void setParams(ParameterType input) {
+ params = input;
+ }
+
+ @Override
+ public final boolean isDone() {
+ return done;
+ }
+
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java
new file mode 100644
index 0000000000..530afdb443
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/AuthenticationHandler.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2018, 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.auth;
+
+import java.io.Closeable;
+
+/**
+ * An {@code AuthenticationHandler} encapsulates a possibly multi-step
+ * authentication protocol. Intended usage:
+ *
+ * <pre>
+ * setParams(something);
+ * start();
+ * sendToken(getToken());
+ * while (!isDone()) {
+ * setParams(receiveMessageAndExtractParams());
+ * process();
+ * Object t = getToken();
+ * if (t != null) {
+ * sendToken(t);
+ * }
+ * }
+ * </pre>
+ *
+ * An {@code AuthenticationHandler} may be stateful and therefore is a
+ * {@link Closeable}.
+ *
+ * @param <ParameterType>
+ * defining the parameter type for {@link #setParams(Object)}
+ * @param <TokenType>
+ * defining the token type for {@link #getToken()}
+ */
+public interface AuthenticationHandler<ParameterType, TokenType>
+ extends Closeable {
+
+ /**
+ * Produces the initial authentication token that can be then retrieved via
+ * {@link #getToken()}.
+ *
+ * @throws Exception
+ * if an error occurs
+ */
+ void start() throws Exception;
+
+ /**
+ * Produces the next authentication token, if any.
+ *
+ * @throws Exception
+ * if an error occurs
+ */
+ void process() throws Exception;
+
+ /**
+ * Sets the parameters for the next token generation via {@link #start()} or
+ * {@link #process()}.
+ *
+ * @param input
+ * to set, may be {@code null}
+ */
+ void setParams(ParameterType input);
+
+ /**
+ * Retrieves the last token generated.
+ *
+ * @return the token, or {@code null} if there is none
+ * @throws Exception
+ * if an error occurs
+ */
+ TokenType getToken() throws Exception;
+
+ /**
+ * Tells whether is authentication mechanism is done (successfully or
+ * unsuccessfully).
+ *
+ * @return whether this authentication is done
+ */
+ boolean isDone();
+
+ @Override
+ void close();
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java
new file mode 100644
index 0000000000..3e1fab34d9
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/BasicAuthentication.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2018, 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.auth;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.net.Authenticator;
+import java.net.Authenticator.RequestorType;
+import java.net.InetSocketAddress;
+import java.net.PasswordAuthentication;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.util.Arrays;
+import java.util.concurrent.CancellationException;
+
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.transport.SshConstants;
+
+/**
+ * An abstract implementation of a username-password authentication. It can be
+ * given an initial known username-password pair; if so, this will be tried
+ * first. Subsequent rounds will then try to obtain a user name and password via
+ * the global {@link Authenticator}.
+ *
+ * @param <ParameterType>
+ * defining the parameter type for the authentication
+ * @param <TokenType>
+ * defining the token type for the authentication
+ */
+public abstract class BasicAuthentication<ParameterType, TokenType>
+ extends AbstractAuthenticationHandler<ParameterType, TokenType> {
+
+ /** The current user name. */
+ protected String user;
+
+ /** The current password. */
+ protected byte[] password;
+
+ /**
+ * Creates a new {@link BasicAuthentication} to authenticate with the given
+ * {@code proxy}.
+ *
+ * @param proxy
+ * {@link InetSocketAddress} of the proxy to connect to
+ * @param initialUser
+ * initial user name to try; may be {@code null}
+ * @param initialPassword
+ * initial password to try, may be {@code null}
+ */
+ public BasicAuthentication(InetSocketAddress proxy, String initialUser,
+ char[] initialPassword) {
+ super(proxy);
+ this.user = initialUser;
+ this.password = convert(initialPassword);
+ }
+
+ @SuppressWarnings("ByteBufferBackingArray")
+ private byte[] convert(char[] pass) {
+ if (pass == null) {
+ return new byte[0];
+ }
+ ByteBuffer bytes = UTF_8.encode(CharBuffer.wrap(pass));
+ byte[] pwd = new byte[bytes.remaining()];
+ bytes.get(pwd);
+ if (bytes.hasArray()) {
+ Arrays.fill(bytes.array(), (byte) 0);
+ }
+ Arrays.fill(pass, '\000');
+ return pwd;
+ }
+
+ /**
+ * Clears the {@link #password}.
+ */
+ protected void clearPassword() {
+ if (password != null) {
+ Arrays.fill(password, (byte) 0);
+ }
+ password = new byte[0];
+ }
+
+ @Override
+ public final void close() {
+ clearPassword();
+ done = true;
+ }
+
+ @Override
+ public final void start() throws Exception {
+ if ((user != null && !user.isEmpty())
+ || (password != null && password.length > 0)) {
+ return;
+ }
+ askCredentials();
+ }
+
+ @Override
+ public void process() throws Exception {
+ askCredentials();
+ }
+
+ /**
+ * Asks for credentials via the global {@link Authenticator}.
+ */
+ protected void askCredentials() {
+ clearPassword();
+ PasswordAuthentication auth = Authenticator
+ .requestPasswordAuthentication(proxy.getHostString(),
+ proxy.getAddress(), proxy.getPort(),
+ SshConstants.SSH_SCHEME,
+ SshdText.get().proxyPasswordPrompt, "Basic", //$NON-NLS-1$
+ null, RequestorType.PROXY);
+ if (auth == null) {
+ user = ""; //$NON-NLS-1$
+ throw new CancellationException(
+ SshdText.get().authenticationCanceled);
+ }
+ user = auth.getUserName();
+ password = convert(auth.getPassword());
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java
new file mode 100644
index 0000000000..439fa896b4
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/auth/GssApiAuthentication.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2018, 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.auth;
+
+import static java.text.MessageFormat.format;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+
+import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.ietf.jgss.GSSContext;
+
+/**
+ * An abstract implementation of a GSS-API multi-round authentication.
+ *
+ * @param <ParameterType>
+ * defining the parameter type for the authentication
+ * @param <TokenType>
+ * defining the token type for the authentication
+ */
+public abstract class GssApiAuthentication<ParameterType, TokenType>
+ extends AbstractAuthenticationHandler<ParameterType, TokenType> {
+
+ private GSSContext context;
+
+ /** The last token generated. */
+ protected byte[] token;
+
+ /**
+ * Creates a new {@link GssApiAuthentication} to authenticate with the given
+ * {@code proxy}.
+ *
+ * @param proxy
+ * the {@link InetSocketAddress} of the proxy to connect to
+ */
+ public GssApiAuthentication(InetSocketAddress proxy) {
+ super(proxy);
+ }
+
+ @Override
+ public void close() {
+ GssApiMechanisms.closeContextSilently(context);
+ context = null;
+ done = true;
+ }
+
+ @Override
+ public final void start() throws Exception {
+ try {
+ context = createContext();
+ context.requestMutualAuth(true);
+ context.requestConf(false);
+ context.requestInteg(false);
+ byte[] empty = new byte[0];
+ token = context.initSecContext(empty, 0, 0);
+ } catch (Exception e) {
+ close();
+ throw e;
+ }
+ }
+
+ @Override
+ public final void process() throws Exception {
+ if (context == null) {
+ throw new IOException(
+ format(SshdText.get().proxyCannotAuthenticate, proxy));
+ }
+ try {
+ byte[] received = extractToken(params);
+ token = context.initSecContext(received, 0, received.length);
+ checkDone();
+ } catch (Exception e) {
+ close();
+ throw e;
+ }
+ }
+
+ private void checkDone() throws Exception {
+ done = context.isEstablished();
+ if (done) {
+ context.dispose();
+ context = null;
+ }
+ }
+
+ /**
+ * Creates the {@link GSSContext} to use.
+ *
+ * @return a fresh {@link GSSContext} to use
+ * @throws Exception
+ * if the context cannot be created
+ */
+ protected abstract GSSContext createContext() throws Exception;
+
+ /**
+ * Extracts the token from the last set parameters.
+ *
+ * @param input
+ * to extract the token from
+ * @return the extracted token, or {@code null} if none
+ * @throws Exception
+ * if an error occurs
+ */
+ protected abstract byte[] extractToken(ParameterType input)
+ throws Exception;
+}
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 &lt; 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/internal/transport/sshd/proxy/AbstractClientProxyConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java
new file mode 100644
index 0000000000..a05d6415fa
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AbstractClientProxyConnector.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.JGitClientSession;
+
+/**
+ * Basic common functionality for a {@link StatefulProxyConnector}.
+ */
+public abstract class AbstractClientProxyConnector
+ implements StatefulProxyConnector {
+
+ private static final long DEFAULT_PROXY_TIMEOUT_NANOS = TimeUnit.SECONDS
+ .toNanos(30L);
+
+ /** Guards {@link #done} and {@link #bufferedCommands}. */
+ private final Object lock = new Object();
+
+ private boolean done;
+
+ private List<Callable<Void>> bufferedCommands = new ArrayList<>();
+
+ private AtomicReference<Runnable> unregister = new AtomicReference<>();
+
+ private long remainingProxyProtocolTime = DEFAULT_PROXY_TIMEOUT_NANOS;
+
+ private long lastProxyOperationTime = 0L;
+
+ /** The ultimate remote address to connect to. */
+ protected final InetSocketAddress remoteAddress;
+
+ /** The proxy address. */
+ protected final InetSocketAddress proxyAddress;
+
+ /** The user to authenticate at the proxy with. */
+ protected String proxyUser;
+
+ /** The password to use for authentication at the proxy. */
+ protected char[] proxyPassword;
+
+ /**
+ * Creates a new {@link AbstractClientProxyConnector}.
+ *
+ * @param proxyAddress
+ * of the proxy server we're connecting to
+ * @param remoteAddress
+ * of the target server to connect to
+ * @param proxyUser
+ * to authenticate at the proxy with; may be {@code null}
+ * @param proxyPassword
+ * to authenticate at the proxy with; may be {@code null}
+ */
+ public AbstractClientProxyConnector(@NonNull InetSocketAddress proxyAddress,
+ @NonNull InetSocketAddress remoteAddress, String proxyUser,
+ char[] proxyPassword) {
+ this.proxyAddress = proxyAddress;
+ this.remoteAddress = remoteAddress;
+ this.proxyUser = proxyUser;
+ this.proxyPassword = proxyPassword == null ? new char[0]
+ : proxyPassword;
+ }
+
+ /**
+ * Initializes this instance. Installs itself as proxy handler on the
+ * session.
+ *
+ * @param session
+ * to initialize for
+ */
+ protected void init(ClientSession session) {
+ long millis = session.getLongProperty(
+ StatefulProxyConnector.TIMEOUT_PROPERTY,
+ 0);
+ remainingProxyProtocolTime = (millis > 0)
+ ? TimeUnit.MILLISECONDS.toNanos(millis)
+ : DEFAULT_PROXY_TIMEOUT_NANOS;
+ if (session instanceof JGitClientSession) {
+ JGitClientSession s = (JGitClientSession) session;
+ unregister.set(() -> s.setProxyHandler(null));
+ s.setProxyHandler(this);
+ } else {
+ // Internal error, no translation
+ throw new IllegalStateException(
+ "Not a JGit session: " + session.getClass().getName()); //$NON-NLS-1$
+ }
+ }
+
+ /**
+ * Obtains the timeout for the whole rest of the proxy connection protocol.
+ *
+ * @return the timeout in milliseconds, always &gt; 0L
+ */
+ protected long getTimeout() {
+ long last = lastProxyOperationTime;
+ long now = System.nanoTime();
+ lastProxyOperationTime = now;
+ long remaining = remainingProxyProtocolTime;
+ if (last != 0L) {
+ long elapsed = now - last;
+ remaining -= elapsed;
+ remainingProxyProtocolTime = remaining;
+ }
+ return Math.max(remaining / 1_000_000L, 10L); // Give it grace period.
+ }
+
+ /**
+ * Adjusts the timeout calculation to not account of elapsed time since the
+ * last time the timeout was gotten. Can be used for instance to ignore time
+ * spent in user dialogs be counted against the overall proxy connection
+ * protocol timeout.
+ */
+ protected void adjustTimeout() {
+ lastProxyOperationTime = System.nanoTime();
+ }
+
+ /**
+ * Sets the "done" flag.
+ *
+ * @param success
+ * whether the connector terminated successfully.
+ * @throws Exception
+ * if starting ssh fails
+ */
+ protected void setDone(boolean success) throws Exception {
+ List<Callable<Void>> buffered;
+ Runnable unset = unregister.getAndSet(null);
+ if (unset != null) {
+ unset.run();
+ }
+ synchronized (lock) {
+ done = true;
+ buffered = bufferedCommands;
+ bufferedCommands = null;
+ }
+ if (success && buffered != null) {
+ for (Callable<Void> starter : buffered) {
+ starter.call();
+ }
+ }
+ }
+
+ @Override
+ public void runWhenDone(Callable<Void> starter) throws Exception {
+ synchronized (lock) {
+ if (!done) {
+ bufferedCommands.add(starter);
+ return;
+ }
+ }
+ starter.call();
+ }
+
+ /**
+ * Clears the proxy password.
+ */
+ protected void clearPassword() {
+ Arrays.fill(proxyPassword, '\000');
+ proxyPassword = new char[0];
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java
new file mode 100644
index 0000000000..691466cde3
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/AuthenticationChallenge.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.eclipse.jgit.annotations.NonNull;
+
+/**
+ * A simple representation of an authentication challenge as sent in a
+ * "WWW-Authenticate" or "Proxy-Authenticate" header. Such challenges start with
+ * a mechanism name, followed either by one single token, or by a list of
+ * key=value pairs.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc7235#section-2.1">RFC 7235, sec.
+ * 2.1</a>
+ */
+public class AuthenticationChallenge {
+
+ private final String mechanism;
+
+ private String token;
+
+ private Map<String, String> arguments;
+
+ /**
+ * Create a new {@link AuthenticationChallenge} with the given mechanism.
+ *
+ * @param mechanism
+ * for the challenge
+ */
+ public AuthenticationChallenge(String mechanism) {
+ this.mechanism = mechanism;
+ }
+
+ /**
+ * Retrieves the authentication mechanism specified by this challenge, for
+ * instance "Basic".
+ *
+ * @return the mechanism name
+ */
+ public String getMechanism() {
+ return mechanism;
+ }
+
+ /**
+ * Retrieves the token of the challenge, if any.
+ *
+ * @return the token, or {@code null} if there is none.
+ */
+ public String getToken() {
+ return token;
+ }
+
+ /**
+ * Retrieves the arguments of the challenge.
+ *
+ * @return a possibly empty map of the key=value arguments of the challenge
+ */
+ @NonNull
+ public Map<String, String> getArguments() {
+ return arguments == null ? Collections.emptyMap() : arguments;
+ }
+
+ void addArgument(String key, String value) {
+ if (arguments == null) {
+ arguments = new LinkedHashMap<>();
+ }
+ arguments.put(key, value);
+ }
+
+ void setToken(String token) {
+ this.token = token;
+ }
+
+ @Override
+ public String toString() {
+ return "AuthenticationChallenge[" + mechanism + ',' + token + ',' //$NON-NLS-1$
+ + (arguments == null ? "<none>" : arguments.toString()) + ']'; //$NON-NLS-1$
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java
new file mode 100644
index 0000000000..b7deb29dc4
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpClientConnector.java
@@ -0,0 +1,372 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.text.MessageFormat.format;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.util.Readable;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.internal.transport.sshd.auth.AuthenticationHandler;
+import org.eclipse.jgit.internal.transport.sshd.auth.BasicAuthentication;
+import org.eclipse.jgit.internal.transport.sshd.auth.GssApiAuthentication;
+import org.eclipse.jgit.util.Base64;
+import org.ietf.jgss.GSSContext;
+
+/**
+ * Simple HTTP proxy connector using Basic Authentication.
+ */
+public class HttpClientConnector extends AbstractClientProxyConnector {
+
+ private static final String HTTP_HEADER_PROXY_AUTHENTICATION = "Proxy-Authentication:"; //$NON-NLS-1$
+
+ private static final String HTTP_HEADER_PROXY_AUTHORIZATION = "Proxy-Authorization:"; //$NON-NLS-1$
+
+ private HttpAuthenticationHandler basic;
+
+ private HttpAuthenticationHandler negotiate;
+
+ private List<HttpAuthenticationHandler> availableAuthentications;
+
+ private Iterator<HttpAuthenticationHandler> clientAuthentications;
+
+ private HttpAuthenticationHandler authenticator;
+
+ private boolean ongoing;
+
+ /**
+ * Creates a new {@link HttpClientConnector}. The connector supports
+ * anonymous proxy connections as well as Basic and Negotiate
+ * authentication.
+ *
+ * @param proxyAddress
+ * of the proxy server we're connecting to
+ * @param remoteAddress
+ * of the target server to connect to
+ */
+ public HttpClientConnector(@NonNull InetSocketAddress proxyAddress,
+ @NonNull InetSocketAddress remoteAddress) {
+ this(proxyAddress, remoteAddress, null, null);
+ }
+
+ /**
+ * Creates a new {@link HttpClientConnector}. The connector supports
+ * anonymous proxy connections as well as Basic and Negotiate
+ * authentication. If a user name and password are given, the connector
+ * tries pre-emptive Basic authentication.
+ *
+ * @param proxyAddress
+ * of the proxy server we're connecting to
+ * @param remoteAddress
+ * of the target server to connect to
+ * @param proxyUser
+ * to authenticate at the proxy with
+ * @param proxyPassword
+ * to authenticate at the proxy with
+ */
+ public HttpClientConnector(@NonNull InetSocketAddress proxyAddress,
+ @NonNull InetSocketAddress remoteAddress, String proxyUser,
+ char[] proxyPassword) {
+ super(proxyAddress, remoteAddress, proxyUser, proxyPassword);
+ basic = new HttpBasicAuthentication();
+ negotiate = new NegotiateAuthentication();
+ availableAuthentications = new ArrayList<>(2);
+ availableAuthentications.add(negotiate);
+ availableAuthentications.add(basic);
+ clientAuthentications = availableAuthentications.iterator();
+ }
+
+ private void close() {
+ HttpAuthenticationHandler current = authenticator;
+ authenticator = null;
+ if (current != null) {
+ current.close();
+ }
+ }
+
+ @Override
+ public void sendClientProxyMetadata(ClientSession sshSession)
+ throws Exception {
+ init(sshSession);
+ IoSession session = sshSession.getIoSession();
+ session.addCloseFutureListener(f -> close());
+ StringBuilder msg = connect();
+ if ((proxyUser != null && !proxyUser.isEmpty())
+ || (proxyPassword != null && proxyPassword.length > 0)) {
+ authenticator = basic;
+ basic.setParams(null);
+ basic.start();
+ msg = authenticate(msg, basic.getToken());
+ clearPassword();
+ proxyUser = null;
+ }
+ ongoing = true;
+ try {
+ send(msg, session);
+ } catch (Exception e) {
+ ongoing = false;
+ throw e;
+ }
+ }
+
+ private void send(StringBuilder msg, IoSession session) throws Exception {
+ byte[] data = eol(msg).toString().getBytes(US_ASCII);
+ Buffer buffer = new ByteArrayBuffer(data.length, false);
+ buffer.putRawBytes(data);
+ session.writeBuffer(buffer).verify(getTimeout());
+ }
+
+ private StringBuilder connect() {
+ StringBuilder msg = new StringBuilder();
+ // Persistent connections are the default in HTTP 1.1 (see RFC 2616),
+ // but let's be explicit.
+ return msg.append(format(
+ "CONNECT {0}:{1} HTTP/1.1\r\nProxy-Connection: keep-alive\r\nConnection: keep-alive\r\nHost: {0}:{1}\r\n", //$NON-NLS-1$
+ remoteAddress.getHostString(),
+ Integer.toString(remoteAddress.getPort())));
+ }
+
+ private StringBuilder authenticate(StringBuilder msg, String token) {
+ msg.append(HTTP_HEADER_PROXY_AUTHORIZATION).append(' ').append(token);
+ return eol(msg);
+ }
+
+ private StringBuilder eol(StringBuilder msg) {
+ return msg.append('\r').append('\n');
+ }
+
+ @Override
+ public void messageReceived(IoSession session, Readable buffer)
+ throws Exception {
+ try {
+ int length = buffer.available();
+ byte[] data = new byte[length];
+ buffer.getRawBytes(data, 0, length);
+ String[] reply = new String(data, US_ASCII)
+ .split("\r\n"); //$NON-NLS-1$
+ handleMessage(session, Arrays.asList(reply));
+ } catch (Exception e) {
+ if (authenticator != null) {
+ authenticator.close();
+ authenticator = null;
+ }
+ ongoing = false;
+ try {
+ setDone(false);
+ } catch (Exception inner) {
+ e.addSuppressed(inner);
+ }
+ throw e;
+ }
+ }
+
+ private void handleMessage(IoSession session, List<String> reply)
+ throws Exception {
+ if (reply.isEmpty() || reply.get(0).isEmpty()) {
+ throw new IOException(
+ format(SshdText.get().proxyHttpUnexpectedReply,
+ proxyAddress, "<empty>")); //$NON-NLS-1$
+ }
+ try {
+ StatusLine status = HttpParser.parseStatusLine(reply.get(0));
+ if (!ongoing) {
+ throw new IOException(format(
+ SshdText.get().proxyHttpUnexpectedReply, proxyAddress,
+ Integer.toString(status.getResultCode()),
+ status.getReason()));
+ }
+ switch (status.getResultCode()) {
+ case HttpURLConnection.HTTP_OK:
+ if (authenticator != null) {
+ authenticator.close();
+ }
+ authenticator = null;
+ ongoing = false;
+ setDone(true);
+ break;
+ case HttpURLConnection.HTTP_PROXY_AUTH:
+ List<AuthenticationChallenge> challenges = HttpParser
+ .getAuthenticationHeaders(reply,
+ HTTP_HEADER_PROXY_AUTHENTICATION);
+ authenticator = selectProtocol(challenges, authenticator);
+ if (authenticator == null) {
+ throw new IOException(
+ format(SshdText.get().proxyCannotAuthenticate,
+ proxyAddress));
+ }
+ String token = authenticator.getToken();
+ if (token == null) {
+ throw new IOException(
+ format(SshdText.get().proxyCannotAuthenticate,
+ proxyAddress));
+ }
+ send(authenticate(connect(), token), session);
+ break;
+ default:
+ throw new IOException(format(SshdText.get().proxyHttpFailure,
+ proxyAddress, Integer.toString(status.getResultCode()),
+ status.getReason()));
+ }
+ } catch (HttpParser.ParseException e) {
+ throw new IOException(
+ format(SshdText.get().proxyHttpUnexpectedReply,
+ proxyAddress, reply.get(0)),
+ e);
+ }
+ }
+
+ private HttpAuthenticationHandler selectProtocol(
+ List<AuthenticationChallenge> challenges,
+ HttpAuthenticationHandler current) throws Exception {
+ if (current != null && !current.isDone()) {
+ AuthenticationChallenge challenge = getByName(challenges,
+ current.getName());
+ if (challenge != null) {
+ current.setParams(challenge);
+ current.process();
+ return current;
+ }
+ }
+ if (current != null) {
+ current.close();
+ }
+ while (clientAuthentications.hasNext()) {
+ HttpAuthenticationHandler next = clientAuthentications.next();
+ if (!next.isDone()) {
+ AuthenticationChallenge challenge = getByName(challenges,
+ next.getName());
+ if (challenge != null) {
+ next.setParams(challenge);
+ next.start();
+ return next;
+ }
+ }
+ }
+ return null;
+ }
+
+ private AuthenticationChallenge getByName(
+ List<AuthenticationChallenge> challenges,
+ String name) {
+ return challenges.stream()
+ .filter(c -> c.getMechanism().equalsIgnoreCase(name))
+ .findFirst().orElse(null);
+ }
+
+ private interface HttpAuthenticationHandler
+ extends AuthenticationHandler<AuthenticationChallenge, String> {
+
+ public String getName();
+ }
+
+ /**
+ * @see <a href="https://tools.ietf.org/html/rfc7617">RFC 7617</a>
+ */
+ private class HttpBasicAuthentication
+ extends BasicAuthentication<AuthenticationChallenge, String>
+ implements HttpAuthenticationHandler {
+
+ private boolean asked;
+
+ public HttpBasicAuthentication() {
+ super(proxyAddress, proxyUser, proxyPassword);
+ }
+
+ @Override
+ public String getName() {
+ return "Basic"; //$NON-NLS-1$
+ }
+
+ @Override
+ protected void askCredentials() {
+ // We ask only once.
+ if (asked) {
+ throw new IllegalStateException(
+ "Basic auth: already asked user for password"); //$NON-NLS-1$
+ }
+ asked = true;
+ super.askCredentials();
+ done = true;
+ }
+
+ @Override
+ public String getToken() throws Exception {
+ if (user.indexOf(':') >= 0) {
+ throw new IOException(format(
+ SshdText.get().proxyHttpInvalidUserName, proxy, user));
+ }
+ byte[] rawUser = user.getBytes(UTF_8);
+ byte[] toEncode = new byte[rawUser.length + 1 + password.length];
+ System.arraycopy(rawUser, 0, toEncode, 0, rawUser.length);
+ toEncode[rawUser.length] = ':';
+ System.arraycopy(password, 0, toEncode, rawUser.length + 1,
+ password.length);
+ Arrays.fill(password, (byte) 0);
+ String result = Base64.encodeBytes(toEncode);
+ Arrays.fill(toEncode, (byte) 0);
+ return getName() + ' ' + result;
+ }
+
+ }
+
+ /**
+ * @see <a href="https://tools.ietf.org/html/rfc4559">RFC 4559</a>
+ */
+ private class NegotiateAuthentication
+ extends GssApiAuthentication<AuthenticationChallenge, String>
+ implements HttpAuthenticationHandler {
+
+ public NegotiateAuthentication() {
+ super(proxyAddress);
+ }
+
+ @Override
+ public String getName() {
+ return "Negotiate"; //$NON-NLS-1$
+ }
+
+ @Override
+ public String getToken() throws Exception {
+ return getName() + ' ' + Base64.encodeBytes(token);
+ }
+
+ @Override
+ protected GSSContext createContext() throws Exception {
+ return GssApiMechanisms.createContext(GssApiMechanisms.SPNEGO,
+ GssApiMechanisms.getCanonicalName(proxyAddress));
+ }
+
+ @Override
+ protected byte[] extractToken(AuthenticationChallenge input)
+ throws Exception {
+ String received = input.getToken();
+ if (received == null) {
+ return new byte[0];
+ }
+ return Base64.decode(received);
+ }
+
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java
new file mode 100644
index 0000000000..ece22af1ce
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/HttpParser.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.eclipse.jgit.util.HttpSupport;
+
+/**
+ * A basic parser for HTTP response headers. Handles status lines and
+ * authentication headers (WWW-Authenticate, Proxy-Authenticate).
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc7230">RFC 7230</a>
+ * @see <a href="https://tools.ietf.org/html/rfc7235">RFC 7235</a>
+ */
+public final class HttpParser {
+
+ /**
+ * An exception indicating some problem parsing HTPP headers.
+ */
+ public static class ParseException extends Exception {
+
+ private static final long serialVersionUID = -1634090143702048640L;
+
+ /**
+ * Creates a new {@link ParseException} without cause.
+ */
+ public ParseException() {
+ super();
+ }
+
+ /**
+ * Creates a new {@link ParseException} with the given {@code cause}.
+ *
+ * @param cause
+ * {@link Throwable} that caused this exception, or
+ * {@code null} if none
+ */
+ public ParseException(Throwable cause) {
+ super(cause);
+ }
+ }
+
+ private HttpParser() {
+ // No instantiation
+ }
+
+ /**
+ * Parse a HTTP response status line.
+ *
+ * @param line
+ * to parse
+ * @return the {@link StatusLine}
+ * @throws ParseException
+ * if the line cannot be parsed or has the wrong HTTP version
+ */
+ public static StatusLine parseStatusLine(String line)
+ throws ParseException {
+ // Format is HTTP/<version> Code Reason
+ int firstBlank = line.indexOf(' ');
+ if (firstBlank < 0) {
+ throw new ParseException();
+ }
+ int secondBlank = line.indexOf(' ', firstBlank + 1);
+ if (secondBlank < 0) {
+ // Accept the line even if the (according to RFC 2616 mandatory)
+ // reason is missing.
+ secondBlank = line.length();
+ }
+ int resultCode;
+ try {
+ resultCode = Integer.parseUnsignedInt(
+ line.substring(firstBlank + 1, secondBlank));
+ } catch (NumberFormatException e) {
+ throw new ParseException(e);
+ }
+ // Again, accept even if the reason is missing
+ String reason = ""; //$NON-NLS-1$
+ if (secondBlank < line.length()) {
+ reason = line.substring(secondBlank + 1);
+ }
+ return new StatusLine(line.substring(0, firstBlank), resultCode,
+ reason);
+ }
+
+ /**
+ * Extract the authentication headers from the header lines. It is assumed
+ * that the first element in {@code reply} is the raw status line as
+ * received from the server. It is skipped. Line processing stops on the
+ * first empty line thereafter.
+ *
+ * @param reply
+ * The complete (header) lines of the HTTP response
+ * @param authenticationHeader
+ * to look for (including the terminating ':'!)
+ * @return a list of {@link AuthenticationChallenge}s found.
+ */
+ public static List<AuthenticationChallenge> getAuthenticationHeaders(
+ List<String> reply, String authenticationHeader) {
+ List<AuthenticationChallenge> challenges = new ArrayList<>();
+ Iterator<String> lines = reply.iterator();
+ // We know we have at least one line. Skip the response line.
+ lines.next();
+ StringBuilder value = null;
+ while (lines.hasNext()) {
+ String line = lines.next();
+ if (line.isEmpty()) {
+ break;
+ }
+ if (Character.isWhitespace(line.charAt(0))) {
+ // Continuation line.
+ if (value == null) {
+ // Skip if we have no current value
+ continue;
+ }
+ // Skip leading whitespace
+ int i = skipWhiteSpace(line, 1);
+ value.append(' ').append(line, i, line.length());
+ continue;
+ }
+ if (value != null) {
+ parseChallenges(challenges, value.toString());
+ value = null;
+ }
+ int firstColon = line.indexOf(':');
+ if (firstColon > 0 && authenticationHeader
+ .equalsIgnoreCase(line.substring(0, firstColon + 1))) {
+ value = new StringBuilder(line.substring(firstColon + 1));
+ }
+ }
+ if (value != null) {
+ parseChallenges(challenges, value.toString());
+ }
+ return challenges;
+ }
+
+ private static void parseChallenges(
+ List<AuthenticationChallenge> challenges,
+ String header) {
+ // Comma-separated list of challenges, each itself a scheme name
+ // followed optionally by either: a comma-separated list of key=value
+ // pairs, where the value may be a quoted string with backslash escapes,
+ // or a single token value, which itself may end in zero or more '='
+ // characters. Ugh.
+ int length = header.length();
+ for (int i = 0; i < length;) {
+ int start = skipWhiteSpace(header, i);
+ int end = HttpSupport.scanToken(header, start);
+ if (end <= start) {
+ break;
+ }
+ AuthenticationChallenge challenge = new AuthenticationChallenge(
+ header.substring(start, end));
+ challenges.add(challenge);
+ i = parseChallenge(challenge, header, end);
+ }
+ }
+
+ private static int parseChallenge(AuthenticationChallenge challenge,
+ String header, int from) {
+ int length = header.length();
+ boolean first = true;
+ for (int start = from; start <= length; first = false) {
+ // Now we have either a single token, which may end in zero or more
+ // equal signs, or a comma-separated list of key=value pairs (with
+ // optional legacy whitespace around the equals sign), where the
+ // value can be either a token or a quoted string.
+ start = skipWhiteSpace(header, start);
+ int end = HttpSupport.scanToken(header, start);
+ if (end == start) {
+ // Nothing found. Either at end or on a comma.
+ if (start < header.length() && header.charAt(start) == ',') {
+ return start + 1;
+ }
+ return start;
+ }
+ int next = skipWhiteSpace(header, end);
+ // Comma, or equals sign, or end of string
+ if (next >= length || header.charAt(next) != '=') {
+ if (first) {
+ // It must be a token
+ challenge.setToken(header.substring(start, end));
+ if (next < length && header.charAt(next) == ',') {
+ next++;
+ }
+ return next;
+ }
+ // This token must be the name of the next authentication
+ // scheme.
+ return start;
+ }
+ int nextStart = skipWhiteSpace(header, next + 1);
+ if (nextStart >= length) {
+ if (next == end) {
+ // '=' immediately after the key, no value: key must be the
+ // token, and the equals sign is part of the token
+ challenge.setToken(header.substring(start, end + 1));
+ } else {
+ // Key without value...
+ challenge.addArgument(header.substring(start, end), null);
+ }
+ return nextStart;
+ }
+ if (nextStart == end + 1 && header.charAt(nextStart) == '=') {
+ // More than one equals sign: must be the single token.
+ end = nextStart + 1;
+ while (end < length && header.charAt(end) == '=') {
+ end++;
+ }
+ challenge.setToken(header.substring(start, end));
+ end = skipWhiteSpace(header, end);
+ if (end < length && header.charAt(end) == ',') {
+ end++;
+ }
+ return end;
+ }
+ if (header.charAt(nextStart) == ',') {
+ if (next == end) {
+ // '=' immediately after the key, no value: key must be the
+ // token, and the equals sign is part of the token
+ challenge.setToken(header.substring(start, end + 1));
+ return nextStart + 1;
+ }
+ // Key without value...
+ challenge.addArgument(header.substring(start, end), null);
+ start = nextStart + 1;
+ } else {
+ if (header.charAt(nextStart) == '"') {
+ int[] nextEnd = { nextStart + 1 };
+ String value = scanQuotedString(header, nextStart + 1,
+ nextEnd);
+ challenge.addArgument(header.substring(start, end), value);
+ start = nextEnd[0];
+ } else {
+ int nextEnd = HttpSupport.scanToken(header, nextStart);
+ challenge.addArgument(header.substring(start, end),
+ header.substring(nextStart, nextEnd));
+ start = nextEnd;
+ }
+ start = skipWhiteSpace(header, start);
+ if (start < length && header.charAt(start) == ',') {
+ start++;
+ }
+ }
+ }
+ return length;
+ }
+
+ private static int skipWhiteSpace(String header, int i) {
+ int length = header.length();
+ while (i < length && Character.isWhitespace(header.charAt(i))) {
+ i++;
+ }
+ return i;
+ }
+
+ private static String scanQuotedString(String header, int from, int[] to) {
+ StringBuilder result = new StringBuilder();
+ int length = header.length();
+ boolean quoted = false;
+ int i = from;
+ while (i < length) {
+ char c = header.charAt(i++);
+ if (quoted) {
+ result.append(c);
+ quoted = false;
+ } else if (c == '\\') {
+ quoted = true;
+ } else if (c == '"') {
+ break;
+ } else {
+ result.append(c);
+ }
+ }
+ to[0] = i;
+ return result.toString();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java
new file mode 100644
index 0000000000..bb227bbac8
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/Socks5ClientConnector.java
@@ -0,0 +1,608 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.text.MessageFormat.format;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.util.Readable;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.BufferUtils;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.internal.transport.sshd.auth.AuthenticationHandler;
+import org.eclipse.jgit.internal.transport.sshd.auth.BasicAuthentication;
+import org.eclipse.jgit.internal.transport.sshd.auth.GssApiAuthentication;
+import org.eclipse.jgit.transport.SshConstants;
+import org.ietf.jgss.GSSContext;
+
+/**
+ * A {@link AbstractClientProxyConnector} to connect through a SOCKS5 proxy.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc1928">RFC 1928</a>
+ */
+public class Socks5ClientConnector extends AbstractClientProxyConnector {
+
+ // private static final byte SOCKS_VERSION_4 = 4;
+ private static final byte SOCKS_VERSION_5 = 5;
+
+ private static final byte SOCKS_CMD_CONNECT = 1;
+ // private static final byte SOCKS5_CMD_BIND = 2;
+ // private static final byte SOCKS5_CMD_UDP_ASSOCIATE = 3;
+
+ // Address types
+
+ private static final byte SOCKS_ADDRESS_IPv4 = 1;
+
+ private static final byte SOCKS_ADDRESS_FQDN = 3;
+
+ private static final byte SOCKS_ADDRESS_IPv6 = 4;
+
+ // Reply codes
+
+ private static final byte SOCKS_REPLY_SUCCESS = 0;
+
+ private static final byte SOCKS_REPLY_FAILURE = 1;
+
+ private static final byte SOCKS_REPLY_FORBIDDEN = 2;
+
+ private static final byte SOCKS_REPLY_NETWORK_UNREACHABLE = 3;
+
+ private static final byte SOCKS_REPLY_HOST_UNREACHABLE = 4;
+
+ private static final byte SOCKS_REPLY_CONNECTION_REFUSED = 5;
+
+ private static final byte SOCKS_REPLY_TTL_EXPIRED = 6;
+
+ private static final byte SOCKS_REPLY_COMMAND_UNSUPPORTED = 7;
+
+ private static final byte SOCKS_REPLY_ADDRESS_UNSUPPORTED = 8;
+
+ /**
+ * Authentication methods for SOCKS5.
+ *
+ * @see <a href=
+ * "https://www.iana.org/assignments/socks-methods/socks-methods.xhtml">SOCKS
+ * Methods, IANA.org</a>
+ */
+ private enum SocksAuthenticationMethod {
+
+ ANONYMOUS(0),
+ GSSAPI(1),
+ PASSWORD(2),
+ // CHALLENGE_HANDSHAKE(3),
+ // CHALLENGE_RESPONSE(5),
+ // SSL(6),
+ // NDS(7),
+ // MULTI_AUTH(8),
+ // JSON(9),
+ NONE_ACCEPTABLE(0xFF);
+
+ private final byte value;
+
+ SocksAuthenticationMethod(int value) {
+ this.value = (byte) value;
+ }
+
+ public byte getValue() {
+ return value;
+ }
+ }
+
+ private enum ProtocolState {
+ NONE,
+
+ INIT {
+ @Override
+ public void handleMessage(Socks5ClientConnector connector,
+ IoSession session, Buffer data) throws Exception {
+ connector.versionCheck(data.getByte());
+ SocksAuthenticationMethod authMethod = connector.getAuthMethod(
+ data.getByte());
+ switch (authMethod) {
+ case ANONYMOUS:
+ connector.sendConnectInfo(session);
+ break;
+ case PASSWORD:
+ connector.doPasswordAuth(session);
+ break;
+ case GSSAPI:
+ connector.doGssApiAuth(session);
+ break;
+ default:
+ throw new IOException(
+ format(SshdText.get().proxyCannotAuthenticate,
+ connector.proxyAddress));
+ }
+ }
+ },
+
+ AUTHENTICATING {
+ @Override
+ public void handleMessage(Socks5ClientConnector connector,
+ IoSession session, Buffer data) throws Exception {
+ connector.authStep(session, data);
+ }
+ },
+
+ CONNECTING {
+ @Override
+ public void handleMessage(Socks5ClientConnector connector,
+ IoSession session, Buffer data) throws Exception {
+ // Special case: when GSS-API authentication completes, the
+ // client moves into CONNECTING as soon as the GSS context is
+ // established and sends the connect request. This is per RFC
+ // 1961. But for the server, RFC 1961 says it _should_ send an
+ // empty token even if none generated when its server side
+ // context is established. That means we may actually get an
+ // empty token here. That message is 4 bytes long (and has
+ // content 0x01, 0x01, 0x00, 0x00). We simply skip this message
+ // if we get it here. If the server for whatever reason sends
+ // back a "GSS failed" message (it shouldn't, at this point)
+ // it will be two bytes 0x01 0xFF, which will fail the version
+ // check.
+ if (data.available() != 4) {
+ connector.versionCheck(data.getByte());
+ connector.establishConnection(data);
+ }
+ }
+ },
+
+ CONNECTED,
+
+ FAILED;
+
+ public void handleMessage(Socks5ClientConnector connector,
+ @SuppressWarnings("unused") IoSession session, Buffer data)
+ throws Exception {
+ throw new IOException(
+ format(SshdText.get().proxySocksUnexpectedMessage,
+ connector.proxyAddress, this,
+ BufferUtils.toHex(data.array())));
+ }
+ }
+
+ private ProtocolState state;
+
+ private AuthenticationHandler<Buffer, Buffer> authenticator;
+
+ private GSSContext context;
+
+ private byte[] authenticationProposals;
+
+ /**
+ * Creates a new {@link Socks5ClientConnector}. The connector supports
+ * anonymous connections as well as username-password or Kerberos5 (GSS-API)
+ * authentication.
+ *
+ * @param proxyAddress
+ * of the proxy server we're connecting to
+ * @param remoteAddress
+ * of the target server to connect to
+ */
+ public Socks5ClientConnector(@NonNull InetSocketAddress proxyAddress,
+ @NonNull InetSocketAddress remoteAddress) {
+ this(proxyAddress, remoteAddress, null, null);
+ }
+
+ /**
+ * Creates a new {@link Socks5ClientConnector}. The connector supports
+ * anonymous connections as well as username-password or Kerberos5 (GSS-API)
+ * authentication.
+ *
+ * @param proxyAddress
+ * of the proxy server we're connecting to
+ * @param remoteAddress
+ * of the target server to connect to
+ * @param proxyUser
+ * to authenticate at the proxy with
+ * @param proxyPassword
+ * to authenticate at the proxy with
+ */
+ public Socks5ClientConnector(@NonNull InetSocketAddress proxyAddress,
+ @NonNull InetSocketAddress remoteAddress,
+ String proxyUser, char[] proxyPassword) {
+ super(proxyAddress, remoteAddress, proxyUser, proxyPassword);
+ this.state = ProtocolState.NONE;
+ }
+
+ @Override
+ public void sendClientProxyMetadata(ClientSession sshSession)
+ throws Exception {
+ init(sshSession);
+ IoSession session = sshSession.getIoSession();
+ // Send the initial request
+ Buffer buffer = new ByteArrayBuffer(5, false);
+ buffer.putByte(SOCKS_VERSION_5);
+ context = getGSSContext(remoteAddress);
+ authenticationProposals = getAuthenticationProposals();
+ buffer.putByte((byte) authenticationProposals.length);
+ buffer.putRawBytes(authenticationProposals);
+ state = ProtocolState.INIT;
+ session.writeBuffer(buffer).verify(getTimeout());
+ }
+
+ private byte[] getAuthenticationProposals() {
+ byte[] proposals = new byte[3];
+ int i = 0;
+ proposals[i++] = SocksAuthenticationMethod.ANONYMOUS.getValue();
+ proposals[i++] = SocksAuthenticationMethod.PASSWORD.getValue();
+ if (context != null) {
+ proposals[i++] = SocksAuthenticationMethod.GSSAPI.getValue();
+ }
+ if (i == proposals.length) {
+ return proposals;
+ }
+ byte[] result = new byte[i];
+ System.arraycopy(proposals, 0, result, 0, i);
+ return result;
+ }
+
+ private void sendConnectInfo(IoSession session) throws Exception {
+ GssApiMechanisms.closeContextSilently(context);
+
+ byte[] rawAddress = getRawAddress(remoteAddress);
+ byte[] remoteName = null;
+ byte type;
+ int length = 0;
+ if (rawAddress == null) {
+ remoteName = remoteAddress.getHostString().getBytes(US_ASCII);
+ if (remoteName == null || remoteName.length == 0) {
+ throw new IOException(
+ format(SshdText.get().proxySocksNoRemoteHostName,
+ remoteAddress));
+ } else if (remoteName.length > 255) {
+ // Should not occur; host names must not be longer than 255
+ // US_ASCII characters. Internal error, no translation.
+ throw new IOException(format(
+ "Proxy host name too long for SOCKS (at most 255 characters): {0}", //$NON-NLS-1$
+ remoteAddress.getHostString()));
+ }
+ type = SOCKS_ADDRESS_FQDN;
+ length = remoteName.length + 1;
+ } else {
+ length = rawAddress.length;
+ type = length == 4 ? SOCKS_ADDRESS_IPv4 : SOCKS_ADDRESS_IPv6;
+ }
+ Buffer buffer = new ByteArrayBuffer(4 + length + 2, false);
+ buffer.putByte(SOCKS_VERSION_5);
+ buffer.putByte(SOCKS_CMD_CONNECT);
+ buffer.putByte((byte) 0); // Reserved
+ buffer.putByte(type);
+ if (remoteName != null) {
+ buffer.putByte((byte) remoteName.length);
+ buffer.putRawBytes(remoteName);
+ } else {
+ buffer.putRawBytes(rawAddress);
+ }
+ int port = remoteAddress.getPort();
+ if (port <= 0) {
+ port = SshConstants.SSH_DEFAULT_PORT;
+ }
+ buffer.putByte((byte) ((port >> 8) & 0xFF));
+ buffer.putByte((byte) (port & 0xFF));
+ state = ProtocolState.CONNECTING;
+ session.writeBuffer(buffer).verify(getTimeout());
+ }
+
+ private void doPasswordAuth(IoSession session) throws Exception {
+ GssApiMechanisms.closeContextSilently(context);
+ authenticator = new SocksBasicAuthentication();
+ session.addCloseFutureListener(f -> close());
+ startAuth(session);
+ }
+
+ private void doGssApiAuth(IoSession session) throws Exception {
+ authenticator = new SocksGssApiAuthentication();
+ session.addCloseFutureListener(f -> close());
+ startAuth(session);
+ }
+
+ private void close() {
+ AuthenticationHandler<?, ?> handler = authenticator;
+ authenticator = null;
+ if (handler != null) {
+ handler.close();
+ }
+ }
+
+ private void startAuth(IoSession session) throws Exception {
+ Buffer buffer = null;
+ try {
+ authenticator.setParams(null);
+ authenticator.start();
+ buffer = authenticator.getToken();
+ state = ProtocolState.AUTHENTICATING;
+ if (buffer == null) {
+ // Internal error; no translation
+ throw new IOException(
+ "No data for proxy authentication with " //$NON-NLS-1$
+ + proxyAddress);
+ }
+ session.writeBuffer(buffer).verify(getTimeout());
+ } finally {
+ if (buffer != null) {
+ buffer.clear(true);
+ }
+ }
+ }
+
+ private void authStep(IoSession session, Buffer input) throws Exception {
+ Buffer buffer = null;
+ try {
+ authenticator.setParams(input);
+ authenticator.process();
+ buffer = authenticator.getToken();
+ if (buffer != null) {
+ session.writeBuffer(buffer).verify(getTimeout());
+ }
+ } finally {
+ if (buffer != null) {
+ buffer.clear(true);
+ }
+ }
+ if (authenticator.isDone()) {
+ sendConnectInfo(session);
+ }
+ }
+
+ private void establishConnection(Buffer data) throws Exception {
+ byte reply = data.getByte();
+ switch (reply) {
+ case SOCKS_REPLY_SUCCESS:
+ state = ProtocolState.CONNECTED;
+ setDone(true);
+ return;
+ case SOCKS_REPLY_FAILURE:
+ throw new IOException(format(
+ SshdText.get().proxySocksFailureGeneral, proxyAddress));
+ case SOCKS_REPLY_FORBIDDEN:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureForbidden,
+ proxyAddress, remoteAddress));
+ case SOCKS_REPLY_NETWORK_UNREACHABLE:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureNetworkUnreachable,
+ proxyAddress, remoteAddress));
+ case SOCKS_REPLY_HOST_UNREACHABLE:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureHostUnreachable,
+ proxyAddress, remoteAddress));
+ case SOCKS_REPLY_CONNECTION_REFUSED:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureRefused,
+ proxyAddress, remoteAddress));
+ case SOCKS_REPLY_TTL_EXPIRED:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureTTL, proxyAddress));
+ case SOCKS_REPLY_COMMAND_UNSUPPORTED:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureUnsupportedCommand,
+ proxyAddress));
+ case SOCKS_REPLY_ADDRESS_UNSUPPORTED:
+ throw new IOException(
+ format(SshdText.get().proxySocksFailureUnsupportedAddress,
+ proxyAddress));
+ default:
+ throw new IOException(format(
+ SshdText.get().proxySocksFailureUnspecified, proxyAddress));
+ }
+ }
+
+ @Override
+ public void messageReceived(IoSession session, Readable buffer)
+ throws Exception {
+ try {
+ // Dispatch according to protocol state
+ ByteArrayBuffer data = new ByteArrayBuffer(buffer.available(),
+ false);
+ data.putBuffer(buffer);
+ data.compact();
+ state.handleMessage(this, session, data);
+ } catch (Exception e) {
+ state = ProtocolState.FAILED;
+ if (authenticator != null) {
+ authenticator.close();
+ authenticator = null;
+ }
+ try {
+ setDone(false);
+ } catch (Exception inner) {
+ e.addSuppressed(inner);
+ }
+ throw e;
+ }
+ }
+
+ private void versionCheck(byte version) throws Exception {
+ if (version != SOCKS_VERSION_5) {
+ throw new IOException(
+ format(SshdText.get().proxySocksUnexpectedVersion,
+ Integer.toString(version & 0xFF)));
+ }
+ }
+
+ private SocksAuthenticationMethod getAuthMethod(byte value) {
+ if (value != SocksAuthenticationMethod.NONE_ACCEPTABLE.getValue()) {
+ for (byte proposed : authenticationProposals) {
+ if (proposed == value) {
+ for (SocksAuthenticationMethod method : SocksAuthenticationMethod
+ .values()) {
+ if (method.getValue() == value) {
+ return method;
+ }
+ }
+ break;
+ }
+ }
+ }
+ return SocksAuthenticationMethod.NONE_ACCEPTABLE;
+ }
+
+ private static byte[] getRawAddress(@NonNull InetSocketAddress address) {
+ InetAddress ipAddress = GssApiMechanisms.resolve(address);
+ return ipAddress == null ? null : ipAddress.getAddress();
+ }
+
+ private static GSSContext getGSSContext(
+ @NonNull InetSocketAddress address) {
+ if (!GssApiMechanisms.getSupportedMechanisms()
+ .contains(GssApiMechanisms.KERBEROS_5)) {
+ return null;
+ }
+ return GssApiMechanisms.createContext(GssApiMechanisms.KERBEROS_5,
+ GssApiMechanisms.getCanonicalName(address));
+ }
+
+ /**
+ * @see <a href="https://tools.ietf.org/html/rfc1929">RFC 1929</a>
+ */
+ private class SocksBasicAuthentication
+ extends BasicAuthentication<Buffer, Buffer> {
+
+ private static final byte SOCKS_BASIC_PROTOCOL_VERSION = 1;
+
+ private static final byte SOCKS_BASIC_AUTH_SUCCESS = 0;
+
+ public SocksBasicAuthentication() {
+ super(proxyAddress, proxyUser, proxyPassword);
+ }
+
+ @Override
+ public void process() throws Exception {
+ // Retries impossible. RFC 1929 specifies that the server MUST
+ // close the connection if authentication is unsuccessful.
+ done = true;
+ if (params.getByte() != SOCKS_BASIC_PROTOCOL_VERSION
+ || params.getByte() != SOCKS_BASIC_AUTH_SUCCESS) {
+ throw new IOException(format(
+ SshdText.get().proxySocksAuthenticationFailed, proxy));
+ }
+ }
+
+ @Override
+ protected void askCredentials() {
+ super.askCredentials();
+ adjustTimeout();
+ }
+
+ @Override
+ public Buffer getToken() throws IOException {
+ if (done) {
+ return null;
+ }
+ try {
+ byte[] rawUser = user.getBytes(UTF_8);
+ if (rawUser.length > 255) {
+ throw new IOException(format(
+ SshdText.get().proxySocksUsernameTooLong, proxy,
+ Integer.toString(rawUser.length), user));
+ }
+
+ if (password.length > 255) {
+ throw new IOException(
+ format(SshdText.get().proxySocksPasswordTooLong,
+ proxy, Integer.toString(password.length)));
+ }
+ ByteArrayBuffer buffer = new ByteArrayBuffer(
+ 3 + rawUser.length + password.length, false);
+ buffer.putByte(SOCKS_BASIC_PROTOCOL_VERSION);
+ buffer.putByte((byte) rawUser.length);
+ buffer.putRawBytes(rawUser);
+ buffer.putByte((byte) password.length);
+ buffer.putRawBytes(password);
+ return buffer;
+ } finally {
+ clearPassword();
+ done = true;
+ }
+ }
+ }
+
+ /**
+ * @see <a href="https://tools.ietf.org/html/rfc1961">RFC 1961</a>
+ */
+ private class SocksGssApiAuthentication
+ extends GssApiAuthentication<Buffer, Buffer> {
+
+ private static final byte SOCKS5_GSSAPI_VERSION = 1;
+
+ private static final byte SOCKS5_GSSAPI_TOKEN = 1;
+
+ private static final int SOCKS5_GSSAPI_FAILURE = 0xFF;
+
+ public SocksGssApiAuthentication() {
+ super(proxyAddress);
+ }
+
+ @Override
+ protected GSSContext createContext() throws Exception {
+ return context;
+ }
+
+ @Override
+ public Buffer getToken() throws Exception {
+ if (token == null) {
+ return null;
+ }
+ Buffer buffer = new ByteArrayBuffer(4 + token.length, false);
+ buffer.putByte(SOCKS5_GSSAPI_VERSION);
+ buffer.putByte(SOCKS5_GSSAPI_TOKEN);
+ buffer.putByte((byte) ((token.length >> 8) & 0xFF));
+ buffer.putByte((byte) (token.length & 0xFF));
+ buffer.putRawBytes(token);
+ return buffer;
+ }
+
+ @Override
+ protected byte[] extractToken(Buffer input) throws Exception {
+ if (context == null) {
+ return null;
+ }
+ int version = input.getUByte();
+ if (version != SOCKS5_GSSAPI_VERSION) {
+ throw new IOException(
+ format(SshdText.get().proxySocksGssApiVersionMismatch,
+ remoteAddress, Integer.toString(version)));
+ }
+ int msgType = input.getUByte();
+ if (msgType == SOCKS5_GSSAPI_FAILURE) {
+ throw new IOException(format(
+ SshdText.get().proxySocksGssApiFailure, remoteAddress));
+ } else if (msgType != SOCKS5_GSSAPI_TOKEN) {
+ throw new IOException(format(
+ SshdText.get().proxySocksGssApiUnknownMessage,
+ remoteAddress, Integer.toHexString(msgType & 0xFF)));
+ }
+ if (input.available() >= 2) {
+ int length = (input.getUByte() << 8) + input.getUByte();
+ if (input.available() >= length) {
+ byte[] value = new byte[length];
+ if (length > 0) {
+ input.getRawBytes(value);
+ }
+ return value;
+ }
+ }
+ throw new IOException(
+ format(SshdText.get().proxySocksGssApiMessageTooShort,
+ remoteAddress));
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java
new file mode 100644
index 0000000000..9607ee7753
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatefulProxyConnector.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+import java.util.concurrent.Callable;
+
+import org.apache.sshd.client.session.ClientProxyConnector;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.util.Readable;
+
+/**
+ * Some proxy connections are stateful and require the exchange of multiple
+ * request-reply messages. The default {@link ClientProxyConnector} has only
+ * support for sending a message; replies get routed through the Ssh session,
+ * and don't get back to this proxy connector. Augment the interface so that the
+ * session can know when to route messages received to the proxy connector, and
+ * when to start handling them itself.
+ */
+public interface StatefulProxyConnector extends ClientProxyConnector {
+
+ /**
+ * A property key for a session property defining the timeout for setting up
+ * the proxy connection.
+ */
+ static final String TIMEOUT_PROPERTY = StatefulProxyConnector.class
+ .getName() + "-timeout"; //$NON-NLS-1$
+
+ /**
+ * Handle a received message.
+ *
+ * @param session
+ * to use for writing data
+ * @param buffer
+ * received data
+ * @throws Exception
+ * if data cannot be read, or the connection attempt fails
+ */
+ void messageReceived(IoSession session, Readable buffer) throws Exception;
+
+ /**
+ * Runs {@code command} once the proxy connection is established. May be
+ * called multiple times; commands are run sequentially. If the proxy
+ * connection is already established, {@code command} is executed directly
+ * synchronously.
+ *
+ * @param command
+ * operation to run
+ * @throws Exception
+ * if the operation is run synchronously and throws an exception
+ */
+ void runWhenDone(Callable<Void> command) throws Exception;
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java
new file mode 100644
index 0000000000..bff4824cac
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/proxy/StatusLine.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2018, 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.proxy;
+
+/**
+ * A very simple representation of a HTTP status line.
+ */
+public class StatusLine {
+
+ private final String version;
+
+ private final int resultCode;
+
+ private final String reason;
+
+ /**
+ * Create a new {@link StatusLine} with the given response code and reason
+ * string.
+ *
+ * @param version
+ * the version string (normally "HTTP/1.1" or "HTTP/1.0")
+ * @param resultCode
+ * the HTTP response code (200, 401, etc.)
+ * @param reason
+ * the reason phrase for the code
+ */
+ public StatusLine(String version, int resultCode, String reason) {
+ this.version = version;
+ this.resultCode = resultCode;
+ this.reason = reason;
+ }
+
+ /**
+ * Retrieves the version string.
+ *
+ * @return the version string
+ */
+ public String getVersion() {
+ return version;
+ }
+
+ /**
+ * Retrieves the HTTP response code.
+ *
+ * @return the code
+ */
+ public int getResultCode() {
+ return resultCode;
+ }
+
+ /**
+ * Retrieves the HTTP reason phrase.
+ *
+ * @return the reason
+ */
+ public String getReason() {
+ return reason;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java
new file mode 100644
index 0000000000..4d2d8b6797
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/CachingSigningKeyDatabase.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+/**
+ * A {@link SigningKeyDatabase} that caches data.
+ * <p>
+ * A signing key database may be used to check keys frequently; it may thus need
+ * to cache some data and it may need to cache data per repository. If an
+ * implementation does cache data, it is responsible itself for refreshing that
+ * cache at appropriate times. Clients can control the cache size somewhat via
+ * {@link #setCacheSize(int)}, although the meaning of the cache size (i.e., its
+ * unit) is left undefined here.
+ * </p>
+ *
+ * @since 7.1
+ */
+public interface CachingSigningKeyDatabase extends SigningKeyDatabase {
+
+ /**
+ * Retrieves the current cache size.
+ *
+ * @return the cache size, or -1 if this database has no cache.
+ */
+ int getCacheSize();
+
+ /**
+ * Sets the cache size to use.
+ *
+ * @param size
+ * the cache size, ignored if this database does not have a
+ * cache.
+ * @throws IllegalArgumentException
+ * if {@code size < 0}
+ */
+ void setCacheSize(int size);
+
+ /**
+ * Discards any cached data. A no-op if the database has no cache.
+ */
+ void clearCache();
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java
new file mode 100644
index 0000000000..eec64c3abd
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SigningKeyDatabase.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import java.io.IOException;
+import java.security.PublicKey;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.signing.ssh.SigningDatabase;
+import org.eclipse.jgit.lib.GpgConfig;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * A database storing meta-information about signing keys and certificates.
+ *
+ * @since 7.1
+ */
+public interface SigningKeyDatabase {
+
+ /**
+ * Obtains the current global instance.
+ *
+ * @return the global {@link SigningKeyDatabase}
+ */
+ static SigningKeyDatabase getInstance() {
+ return SigningDatabase.getInstance();
+ }
+
+ /**
+ * Sets the global {@link SigningKeyDatabase}.
+ *
+ * @param database
+ * to set; if {@code null} a default database using the OpenSSH
+ * allowed signers file and the OpenSSH revocation list mechanism
+ * is used.
+ * @return the previously set {@link SigningKeyDatabase}
+ */
+ static SigningKeyDatabase setInstance(SigningKeyDatabase database) {
+ return SigningDatabase.setInstance(database);
+ }
+
+ /**
+ * Determines whether the gives key has been revoked.
+ *
+ * @param repository
+ * {@link Repository} the key is being used in
+ * @param config
+ * {@link GpgConfig} to use
+ * @param key
+ * {@link PublicKey} to check
+ * @return {@code true} if the key has been revoked, {@code false} otherwise
+ * @throws IOException
+ * if an I/O problem occurred
+ */
+ boolean isRevoked(@NonNull Repository repository, @NonNull GpgConfig config,
+ @NonNull PublicKey key) throws IOException;
+
+ /**
+ * Checks whether the given key is allowed to be used for signing, and if
+ * allowed returns the principal.
+ *
+ * @param repository
+ * {@link Repository} the key is being used in
+ * @param config
+ * {@link GpgConfig} to use
+ * @param key
+ * {@link PublicKey} to check
+ * @param namespace
+ * of the signature
+ * @param ident
+ * optional {@link PersonIdent} giving a signer's e-mail address
+ * and a signature time
+ * @return {@code null} if the database does not contain any information
+ * about the given key; the principal if it does and all checks
+ * passed
+ * @throws IOException
+ * if an I/O problem occurred
+ * @throws VerificationException
+ * if the database contains information about the key and the
+ * checks determined that the key is not allowed to be used for
+ * signing
+ */
+ String isAllowed(@NonNull Repository repository, @NonNull GpgConfig config,
+ @NonNull PublicKey key, @NonNull String namespace,
+ PersonIdent ident) throws IOException, VerificationException;
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java
new file mode 100644
index 0000000000..c315428c33
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignatureVerifierFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import org.eclipse.jgit.lib.GpgConfig.GpgFormat;
+import org.eclipse.jgit.lib.SignatureVerifier;
+import org.eclipse.jgit.internal.signing.ssh.SshSignatureVerifier;
+import org.eclipse.jgit.lib.SignatureVerifierFactory;
+
+/**
+ * Factory creating {@link SshSignatureVerifier}s.
+ *
+ * @since 7.1
+ */
+public final class SshSignatureVerifierFactory
+ implements SignatureVerifierFactory {
+
+ @Override
+ public GpgFormat getType() {
+ return GpgFormat.SSH;
+ }
+
+ @Override
+ public SignatureVerifier create() {
+ return new SshSignatureVerifier();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java
new file mode 100644
index 0000000000..5459b5360a
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/SshSignerFactory.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+import org.eclipse.jgit.lib.GpgConfig.GpgFormat;
+import org.eclipse.jgit.lib.Signer;
+import org.eclipse.jgit.internal.signing.ssh.SshSigner;
+import org.eclipse.jgit.lib.SignerFactory;
+
+/**
+ * Factory creating {@link SshSigner}s.
+ *
+ * @since 7.1
+ */
+public final class SshSignerFactory implements SignerFactory {
+
+ @Override
+ public GpgFormat getType() {
+ return GpgFormat.SSH;
+ }
+
+ @Override
+ public Signer create() {
+ return new SshSigner();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java
new file mode 100644
index 0000000000..cd77111813
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/signing/ssh/VerificationException.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024, 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.signing.ssh;
+
+/**
+ * An exception giving details about a failed
+ * {@link SigningKeyDatabase#isAllowed(org.eclipse.jgit.lib.Repository, org.eclipse.jgit.lib.GpgConfig, java.security.PublicKey, String, org.eclipse.jgit.lib.PersonIdent)}
+ * validation.
+ *
+ * @since 7.1
+ */
+public class VerificationException extends Exception {
+
+ private static final long serialVersionUID = 313760495170326160L;
+
+ private final boolean expired;
+
+ private final String reason;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param expired
+ * whether the checked public key or certificate was expired
+ * @param reason
+ * describing the check failure
+ */
+ public VerificationException(boolean expired, String reason) {
+ this.expired = expired;
+ this.reason = reason;
+ }
+
+ @Override
+ public String getMessage() {
+ return reason;
+ }
+
+ /**
+ * Tells whether the check failed because the public key was expired.
+ *
+ * @return {@code true} if the check failed because the public key was
+ * expired, {@code false} otherwise
+ */
+ public boolean isExpired() {
+ return expired;
+ }
+
+ /**
+ * Retrieves the check failure reason.
+ *
+ * @return the reason description
+ */
+ public String getReason() {
+ return reason;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java
new file mode 100644
index 0000000000..b0145277e8
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/DefaultProxyDataFactory.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+
+/**
+ * A default implementation of a {@link ProxyDataFactory} based on the standard
+ * {@link java.net.ProxySelector}.
+ *
+ * @since 5.2
+ */
+public class DefaultProxyDataFactory implements ProxyDataFactory {
+
+ @Override
+ public ProxyData get(InetSocketAddress remoteAddress) {
+ try {
+ List<Proxy> proxies = ProxySelector.getDefault()
+ .select(new URI(
+ "socket://" + remoteAddress.getHostString())); //$NON-NLS-1$
+ ProxyData data = getData(proxies, Proxy.Type.SOCKS);
+ if (data == null) {
+ proxies = ProxySelector.getDefault()
+ .select(new URI(Proxy.Type.HTTP.name(),
+ "//" + remoteAddress.getHostString(), //$NON-NLS-1$
+ null));
+ data = getData(proxies, Proxy.Type.HTTP);
+ }
+ return data;
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+
+ private ProxyData getData(List<Proxy> proxies, Proxy.Type type) {
+ Proxy proxy = proxies.stream().filter(p -> type == p.type()).findFirst()
+ .orElse(null);
+ if (proxy == null) {
+ return null;
+ }
+ SocketAddress address = proxy.address();
+ if (!(address instanceof InetSocketAddress)) {
+ return null;
+ }
+ switch (type) {
+ case HTTP:
+ return new ProxyData(proxy);
+ case SOCKS:
+ return new ProxyData(proxy);
+ default:
+ return null;
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java
new file mode 100644
index 0000000000..23f46d8d6c
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/IdentityPasswordProvider.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import static java.text.MessageFormat.format;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
+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.util.StringUtils;
+
+/**
+ * A {@link KeyPasswordProvider} based on a {@link CredentialsProvider}.
+ *
+ * @since 5.2
+ */
+public class IdentityPasswordProvider implements KeyPasswordProvider {
+
+ private CredentialsProvider provider;
+
+ /**
+ * The number of times to ask successively for a password for a given
+ * identity resource.
+ */
+ private int attempts = 1;
+
+ /**
+ * A simple state object for repeated attempts to get a password for a
+ * resource.
+ */
+ protected static class State {
+
+ private int count = 0;
+
+ private char[] password;
+
+ /**
+ * Obtains the current count. The initial count is zero.
+ *
+ * @return the count
+ */
+ public int getCount() {
+ return count;
+ }
+
+ /**
+ * Increments the current count. Should be called for each new attempt
+ * to get a password.
+ *
+ * @return the incremented count.
+ */
+ public int incCount() {
+ return ++count;
+ }
+
+ /**
+ * Remembers the password.
+ *
+ * @param password
+ * the password
+ */
+ public void setPassword(char[] password) {
+ if (this.password != null) {
+ Arrays.fill(this.password, '\000');
+ }
+ if (password != null) {
+ this.password = password.clone();
+ } else {
+ this.password = null;
+ }
+ }
+
+ /**
+ * Retrieves the password from the current attempt.
+ *
+ * @return the password, or {@code null} if none was obtained
+ */
+ public char[] getPassword() {
+ return password;
+ }
+ }
+
+ /**
+ * Counts per resource key.
+ */
+ private final Map<URIish, State> current = new HashMap<>();
+
+ /**
+ * Creates a new {@link IdentityPasswordProvider} to get the passphrase for
+ * an encrypted identity.
+ *
+ * @param provider
+ * to use
+ */
+ public IdentityPasswordProvider(CredentialsProvider provider) {
+ this.provider = provider;
+ }
+
+ @Override
+ public void setAttempts(int numberOfPasswordPrompts) {
+ if (numberOfPasswordPrompts <= 0) {
+ throw new IllegalArgumentException(
+ "Number of password prompts must be >= 1"); //$NON-NLS-1$
+ }
+ attempts = numberOfPasswordPrompts;
+ }
+
+ @Override
+ public int getAttempts() {
+ return Math.max(1, attempts);
+ }
+
+ @Override
+ public char[] getPassphrase(URIish uri, int attempt) throws IOException {
+ return getPassword(uri, attempt,
+ current.computeIfAbsent(uri, r -> new State()));
+ }
+
+ /**
+ * Retrieves a password to decrypt a private key.
+ *
+ * @param uri
+ * identifying the resource to obtain a password for
+ * @param attempt
+ * number of previous attempts to get a passphrase
+ * @param state
+ * encapsulating state information about attempts to get the
+ * password
+ * @return the password, or {@code null} or the empty string if none
+ * available.
+ * @throws IOException
+ * if an error occurs
+ */
+ protected char[] getPassword(URIish uri, int attempt, @NonNull State state)
+ throws IOException {
+ state.setPassword(null);
+ state.incCount();
+ String message = state.count == 1 ? SshdText.get().keyEncryptedMsg
+ : SshdText.get().keyEncryptedRetry;
+ char[] pass = getPassword(uri, format(message, uri));
+ state.setPassword(pass);
+ return pass;
+ }
+
+ /**
+ * Retrieves the JGit {@link CredentialsProvider} to use for user
+ * interaction.
+ *
+ * @return the {@link CredentialsProvider} or {@code null} if none
+ * configured
+ * @since 5.10
+ */
+ protected CredentialsProvider getCredentialsProvider() {
+ return provider;
+ }
+
+ /**
+ * Obtains the passphrase/password for an encrypted private key via the
+ * {@link #getCredentialsProvider() configured CredentialsProvider}.
+ *
+ * @param uri
+ * identifying the resource to obtain a password for
+ * @param message
+ * optional message text to display; may be {@code null} or empty
+ * if none
+ * @return the password entered, or {@code null} if no
+ * {@link CredentialsProvider} is configured or none was entered
+ * @throws java.util.concurrent.CancellationException
+ * if the user canceled the operation
+ * @since 5.10
+ */
+ protected char[] getPassword(URIish uri, String message) {
+ if (provider == null) {
+ return null;
+ }
+ boolean haveMessage = !StringUtils.isEmptyOrNull(message);
+ List<CredentialItem> items = new ArrayList<>(haveMessage ? 2 : 1);
+ if (haveMessage) {
+ items.add(new CredentialItem.InformationalMessage(message));
+ }
+ CredentialItem.Password password = new CredentialItem.Password(
+ SshdText.get().keyEncryptedPrompt);
+ items.add(password);
+ try {
+ boolean completed = provider.get(uri, items);
+ char[] pass = password.getValue();
+ if (!completed) {
+ cancelAuthentication();
+ return null;
+ }
+ return pass == null ? null : pass.clone();
+ } finally {
+ password.clear();
+ }
+ }
+
+ /**
+ * Cancels the authentication process. Called by
+ * {@link #getPassword(URIish, String)} when the user interaction has been
+ * canceled. If this throws a
+ * {@link java.util.concurrent.CancellationException}, the authentication
+ * process is aborted; otherwise it may continue with the next configured
+ * authentication mechanism, if any.
+ * <p>
+ * This default implementation always throws a
+ * {@link java.util.concurrent.CancellationException}.
+ * </p>
+ *
+ * @throws java.util.concurrent.CancellationException
+ * always
+ * @since 5.10
+ */
+ protected void cancelAuthentication() {
+ throw new AuthenticationCanceledException();
+ }
+
+ /**
+ * Invoked to inform the password provider about the decoding result.
+ *
+ * @param uri
+ * identifying the key resource the key was attempted to be
+ * loaded from
+ * @param state
+ * associated with this key
+ * @param password
+ * the password that was attempted
+ * @param err
+ * the attempt result - {@code null} for success
+ * @return how to proceed in case of error
+ * @throws IOException
+ * if an IO error occurred
+ * @throws GeneralSecurityException
+ * something went wrong
+ */
+ protected boolean keyLoaded(URIish uri,
+ State state, char[] password, Exception err)
+ throws IOException, GeneralSecurityException {
+ if (err == null || password == null) {
+ // Success, or an error before we even asked for a password (could
+ // also be a non-encrypted key, or a user cancellation): don't
+ // retry.
+ return false;
+ }
+ if (state != null && state.getCount() < attempts) {
+ // We asked for a password, and have not yet exhausted the number of
+ // attempts. Assume the password was incorrect.
+ return true;
+ }
+ // Attempts exhausted
+ if (err instanceof GeneralSecurityException) {
+ // Top-level exception with a better exception message. The
+ // framework would otherwise re-throw 'err'.
+ throw new InvalidKeyException(
+ format(SshdText.get().identityFileCannotDecrypt, uri), err);
+ }
+ // I/O error.
+ return false;
+ }
+
+ @Override
+ public boolean keyLoaded(URIish uri, int attempt, Exception error)
+ throws IOException, GeneralSecurityException {
+ State state = null;
+ boolean retry = false;
+ try {
+ state = current.get(uri);
+ retry = keyLoaded(uri, state,
+ state == null ? null : state.getPassword(), error);
+ } finally {
+ if (state != null) {
+ state.setPassword(null);
+ }
+ if (!retry) {
+ current.remove(uri);
+ }
+ }
+ return retry;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/JGitKeyCache.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/JGitKeyCache.java
new file mode 100644
index 0000000000..d6a34ed979
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/JGitKeyCache.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import javax.security.auth.DestroyFailedException;
+
+/**
+ * A simple {@link KeyCache}. JGit uses one such cache in its
+ * {@link SshdSessionFactory} to avoid loading keys multiple times.
+ *
+ * @since 5.2
+ */
+public class JGitKeyCache implements KeyCache {
+
+ private AtomicReference<Map<Path, KeyPair>> cache = new AtomicReference<>(
+ new ConcurrentHashMap<>());
+
+ @Override
+ public KeyPair get(Path path,
+ Function<? super Path, ? extends KeyPair> loader) {
+ return cache.get().computeIfAbsent(path, loader);
+ }
+
+ @Override
+ public void close() {
+ Map<Path, KeyPair> map = cache.getAndSet(null);
+ if (map == null) {
+ return;
+ }
+ for (KeyPair k : map.values()) {
+ PrivateKey p = k.getPrivate();
+ try {
+ p.destroy();
+ } catch (DestroyFailedException e) {
+ // Ignore here. We did our best.
+ }
+ }
+ map.clear();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyCache.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyCache.java
new file mode 100644
index 0000000000..d0f1c58416
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyCache.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.util.function.Function;
+
+/**
+ * A cache for {@link KeyPair}s.
+ *
+ * @since 5.2
+ */
+public interface KeyCache {
+
+ /**
+ * Obtains a {@link KeyPair} from the cache. Implementations must be
+ * thread-safe.
+ *
+ * @param path
+ * of the key
+ * @param loader
+ * to load the key if it isn't present in the cache yet
+ * @return the {@link KeyPair}, or {@code null} if not present and could not
+ * be loaded
+ */
+ KeyPair get(Path path, Function<? super Path, ? extends KeyPair> loader);
+
+ /**
+ * Removes all {@link KeyPair} from this cache and destroys their private
+ * keys. This cache instance must not be used anymore thereafter.
+ */
+ void close();
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProvider.java
new file mode 100644
index 0000000000..a80a5d166d
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProvider.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * A {@code KeyPasswordProvider} provides passwords for encrypted private keys.
+ *
+ * @since 5.2
+ */
+public interface KeyPasswordProvider {
+
+ /**
+ * Obtains a passphrase to use to decrypt an ecrypted private key. Returning
+ * {@code null} or an empty array will skip this key. To cancel completely,
+ * the operation should raise
+ * {@link java.util.concurrent.CancellationException}.
+ *
+ * @param uri
+ * identifying the key resource that is being attempted to be
+ * loaded
+ * @param attempt
+ * the number of previous attempts to get a passphrase; &gt;= 0
+ * @return the passphrase
+ * @throws IOException
+ * if no password can be obtained
+ */
+ char[] getPassphrase(URIish uri, int attempt) throws IOException;
+
+ /**
+ * Define the maximum number of attempts to get a passphrase that should be
+ * attempted for one identity resource through this provider.
+ *
+ * @param maxNumberOfAttempts
+ * number of times to ask for a passphrase;
+ * {@link IllegalArgumentException} may be thrown if &lt;= 0
+ */
+ void setAttempts(int maxNumberOfAttempts);
+
+ /**
+ * Gets the maximum number of attempts to get a passphrase that should be
+ * attempted for one identity resource through this provider. The default
+ * return 1.
+ *
+ * @return the number of times to ask for a passphrase; should be &gt;= 1.
+ */
+ default int getAttempts() {
+ return 1;
+ }
+
+ /**
+ * Invoked after a key has been loaded. If this raises an exception, the
+ * original {@code error} is lost unless it is attached to that exception.
+ *
+ * @param uri
+ * identifying the key resource the key was attempted to be
+ * loaded from
+ * @param attempt
+ * the number of times {@link #getPassphrase(URIish, int)} had
+ * been called; zero indicates that {@code uri} refers to a
+ * non-encrypted key
+ * @param error
+ * {@code null} if the key was loaded successfully; otherwise an
+ * exception indicating why the key could not be loaded
+ * @return {@code true} to re-try again; {@code false} to re-raise the
+ * {@code error} exception; Ignored if the key was loaded
+ * successfully, i.e., if {@code error == null}.
+ * @throws IOException
+ * if an IO error occurred
+ * @throws GeneralSecurityException
+ * something went wrong
+ */
+ boolean keyLoaded(URIish uri, int attempt, Exception error)
+ throws IOException, GeneralSecurityException;
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java
new file mode 100644
index 0000000000..0537300b24
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/KeyPasswordProviderFactory.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024, 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.transport.sshd;
+
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.transport.CredentialsProvider;
+
+/**
+ * Maintains a static singleton instance of a factory to create a
+ * {@link KeyPasswordProvider} from a {@link CredentialsProvider}.
+ *
+ * @since 7.1
+ */
+public final class KeyPasswordProviderFactory {
+
+ /**
+ * Creates a {@link KeyPasswordProvider} from a {@link CredentialsProvider}.
+ */
+ @FunctionalInterface
+ public interface KeyPasswordProviderCreator
+ extends Function<CredentialsProvider, KeyPasswordProvider> {
+ // Nothing
+ }
+
+ private static final KeyPasswordProviderCreator DEFAULT = IdentityPasswordProvider::new;
+
+ private static AtomicReference<KeyPasswordProviderCreator> INSTANCE = new AtomicReference<>(
+ DEFAULT);
+
+ private KeyPasswordProviderFactory() {
+ // No instantiation
+ }
+
+ /**
+ * Retrieves the currently set {@link KeyPasswordProviderCreator}.
+ *
+ * @return the {@link KeyPasswordProviderCreator}
+ */
+ @NonNull
+ public static KeyPasswordProviderCreator getInstance() {
+ return INSTANCE.get();
+ }
+
+ /**
+ * Sets a new {@link KeyPasswordProviderCreator}.
+ *
+ * @param provider
+ * to set; if {@code null}, sets a default provider.
+ * @return the previously set {@link KeyPasswordProviderCreator}
+ */
+ @NonNull
+ public static KeyPasswordProviderCreator setInstance(
+ KeyPasswordProviderCreator provider) {
+ if (provider == null) {
+ return INSTANCE.getAndSet(DEFAULT);
+ }
+ return INSTANCE.getAndSet(provider);
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java
new file mode 100644
index 0000000000..25c1dc1607
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyData.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.util.Arrays;
+
+import org.eclipse.jgit.annotations.NonNull;
+
+/**
+ * A DTO encapsulating the data needed to connect through a proxy server.
+ *
+ * @since 5.2
+ */
+public class ProxyData {
+
+ private final @NonNull Proxy proxy;
+
+ private final String proxyUser;
+
+ private final char[] proxyPassword;
+
+ /**
+ * Creates a new {@link ProxyData} instance without user name or password.
+ *
+ * @param proxy
+ * to connect to; must not be {@link java.net.Proxy.Type#DIRECT}
+ * and must have an {@link InetSocketAddress}.
+ */
+ public ProxyData(@NonNull Proxy proxy) {
+ this(proxy, null, null);
+ }
+
+ /**
+ * Creates a new {@link ProxyData} instance.
+ *
+ * @param proxy
+ * to connect to; must not be {@link java.net.Proxy.Type#DIRECT}
+ * and must have an {@link InetSocketAddress}.
+ * @param proxyUser
+ * to use for log-in to the proxy, may be {@code null}
+ * @param proxyPassword
+ * to use for log-in to the proxy, may be {@code null}
+ */
+ public ProxyData(@NonNull Proxy proxy, String proxyUser,
+ char[] proxyPassword) {
+ this.proxy = proxy;
+ if (!(proxy.address() instanceof InetSocketAddress)) {
+ // Internal error not translated
+ throw new IllegalArgumentException(
+ "Proxy does not have an InetSocketAddress"); //$NON-NLS-1$
+ }
+ this.proxyUser = proxyUser;
+ this.proxyPassword = proxyPassword == null ? null
+ : proxyPassword.clone();
+ }
+
+ /**
+ * Obtains the remote {@link InetSocketAddress} of the proxy to connect to.
+ *
+ * @return the remote address of the proxy
+ */
+ @NonNull
+ public Proxy getProxy() {
+ return proxy;
+ }
+
+ /**
+ * Obtains the user to log in at the proxy with.
+ *
+ * @return the user name, or {@code null} if none
+ */
+ public String getUser() {
+ return proxyUser;
+ }
+
+ /**
+ * Obtains a copy of the internally stored password.
+ *
+ * @return the password or {@code null} if none
+ */
+ public char[] getPassword() {
+ return proxyPassword == null ? null : proxyPassword.clone();
+ }
+
+ /**
+ * Clears the stored password, if any.
+ */
+ public void clearPassword() {
+ if (proxyPassword != null) {
+ Arrays.fill(proxyPassword, '\000');
+ }
+ }
+
+} \ No newline at end of file
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java
new file mode 100644
index 0000000000..fb2feafe54
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ProxyDataFactory.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+import java.net.InetSocketAddress;
+
+/**
+ * Interface for obtaining {@link ProxyData} to connect through some proxy.
+ *
+ * @since 5.2
+ */
+public interface ProxyDataFactory {
+
+ /**
+ * Get the {@link ProxyData} to connect to a proxy. It should return a
+ * <em>new</em> {@link ProxyData} instance every time; if the returned
+ * {@link ProxyData} contains a password, the {@link SshdSession} will clear
+ * it once it is no longer needed.
+ *
+ * @param remoteAddress
+ * to connect to
+ * @return the {@link ProxyData} or {@code null} if a direct connection is
+ * to be made
+ */
+ ProxyData get(InetSocketAddress remoteAddress);
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ServerKeyDatabase.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ServerKeyDatabase.java
new file mode 100644
index 0000000000..b1b3c1808a
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/ServerKeyDatabase.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2019 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.transport.sshd;
+
+import java.net.InetSocketAddress;
+import java.security.PublicKey;
+import java.util.List;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.transport.CredentialsProvider;
+
+/**
+ * An interface for a database of known server keys, supporting finding all
+ * known keys and also deciding whether a server key is to be accepted.
+ * <p>
+ * Connection addresses are given as strings of the format
+ * {@code [hostName]:port} if using a non-standard port (i.e., not port 22),
+ * otherwise just {@code hostname}.
+ * </p>
+ *
+ * @since 5.5
+ */
+public interface ServerKeyDatabase {
+
+ /**
+ * Retrieves all known and not revoked host keys for the given addresses.
+ *
+ * @param connectAddress
+ * IP address the session tried to connect to
+ * @param remoteAddress
+ * IP address as reported for the remote end point
+ * @param config
+ * giving access to potentially interesting configuration
+ * settings
+ * @return the list of known and not revoked keys for the given addresses
+ */
+ @NonNull
+ List<PublicKey> lookup(@NonNull String connectAddress,
+ @NonNull InetSocketAddress remoteAddress,
+ @NonNull Configuration config);
+
+ /**
+ * Determines whether to accept a received server host key.
+ *
+ * @param connectAddress
+ * IP address the session tried to connect to
+ * @param remoteAddress
+ * IP address as reported for the remote end point
+ * @param serverKey
+ * received from the remote end
+ * @param config
+ * giving access to potentially interesting configuration
+ * settings
+ * @param provider
+ * for interacting with the user, if required; may be
+ * {@code null}
+ * @return {@code true} if the serverKey is accepted, {@code false}
+ * otherwise
+ */
+ boolean accept(@NonNull String connectAddress,
+ @NonNull InetSocketAddress remoteAddress,
+ @NonNull PublicKey serverKey,
+ @NonNull Configuration config, CredentialsProvider provider);
+
+ /**
+ * A simple provider for ssh config settings related to host key checking.
+ * An instance is created by the JGit sshd framework and passed into
+ * {@link ServerKeyDatabase#lookup(String, InetSocketAddress, Configuration)}
+ * and
+ * {@link ServerKeyDatabase#accept(String, InetSocketAddress, PublicKey, Configuration, CredentialsProvider)}.
+ */
+ interface Configuration {
+
+ /**
+ * Retrieves the list of file names from the "UserKnownHostsFile" ssh
+ * config.
+ *
+ * @return the list as configured, with ~ already replaced
+ */
+ List<String> getUserKnownHostsFiles();
+
+ /**
+ * Retrieves the list of file names from the "GlobalKnownHostsFile" ssh
+ * config.
+ *
+ * @return the list as configured, with ~ already replaced
+ */
+ List<String> getGlobalKnownHostsFiles();
+
+ /**
+ * The possible values for the "StrictHostKeyChecking" ssh config.
+ */
+ enum StrictHostKeyChecking {
+ /**
+ * "ask"; default: ask the user whether to accept (and store) a new
+ * or mismatched key.
+ */
+ ASK,
+ /**
+ * "yes", "on": never accept new or mismatched keys.
+ */
+ REQUIRE_MATCH,
+ /**
+ * "no", "off": always accept new or mismatched keys.
+ */
+ ACCEPT_ANY,
+ /**
+ * "accept-new": accept new keys, but never accept modified keys.
+ */
+ ACCEPT_NEW
+ }
+
+ /**
+ * Obtains the value of the "StrictHostKeyChecking" ssh config.
+ *
+ * @return the {@link StrictHostKeyChecking}
+ */
+ @NonNull
+ StrictHostKeyChecking getStrictHostKeyChecking();
+
+ /**
+ * Obtains the value of the "HashKnownHosts" ssh config.
+ *
+ * @return {@code true} if new entries should be stored with hashed host
+ * information, {@code false} otherwise
+ */
+ boolean getHashKnownHosts();
+
+ /**
+ * Obtains the user name used in the connection attempt.
+ *
+ * @return the user name
+ */
+ @NonNull
+ String getUsername();
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java
new file mode 100644
index 0000000000..aee6393105
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SessionCloseListener.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2018, 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.transport.sshd;
+
+/**
+ * A {@code SessionCloseListener} is invoked when a {@link SshdSession} is
+ * closed.
+ *
+ * @since 5.2
+ */
+@FunctionalInterface
+public interface SessionCloseListener {
+
+ /**
+ * Invoked when a {@link SshdSession} has been closed.
+ *
+ * @param session
+ * that was closed.
+ */
+ void sessionClosed(SshdSession session);
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java
new file mode 100644
index 0000000000..96316ba1aa
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java
@@ -0,0 +1,606 @@
+/*
+ * Copyright (C) 2018, 2022 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.transport.sshd;
+
+import static java.text.MessageFormat.format;
+import static org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE;
+import static org.apache.sshd.sftp.SftpModuleProperties.SFTP_CHANNEL_OPEN_TIMEOUT;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.channel.ChannelExec;
+import org.apache.sshd.client.channel.ClientChannelEvent;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.future.ConnectFuture;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.session.forward.PortForwardingTracker;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.future.CloseFuture;
+import org.apache.sshd.common.future.SshFutureListener;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.io.functors.IOFunction;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.apache.sshd.sftp.client.SftpClient;
+import org.apache.sshd.sftp.client.SftpClient.CopyMode;
+import org.apache.sshd.sftp.client.SftpClientFactory;
+import org.apache.sshd.sftp.common.SftpException;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.errors.TransportException;
+import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
+import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
+import org.eclipse.jgit.internal.transport.sshd.AuthenticationLogger;
+import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.transport.FtpChannel;
+import org.eclipse.jgit.transport.RemoteSession2;
+import org.eclipse.jgit.transport.SshConstants;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An implementation of {@link org.eclipse.jgit.transport.RemoteSession
+ * RemoteSession} based on Apache MINA sshd.
+ *
+ * @since 5.2
+ */
+public class SshdSession implements RemoteSession2 {
+
+ private static final Logger LOG = LoggerFactory
+ .getLogger(SshdSession.class);
+
+ private static final Pattern SHORT_SSH_FORMAT = Pattern
+ .compile("[-\\w.]+(?:@[-\\w.]+)?(?::\\d+)?"); //$NON-NLS-1$
+
+ private static final int MAX_DEPTH = 10;
+
+ private final CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>();
+
+ private final URIish uri;
+
+ private SshClient client;
+
+ private ClientSession session;
+
+ SshdSession(URIish uri, Supplier<SshClient> clientFactory) {
+ this.uri = uri;
+ this.client = clientFactory.get();
+ }
+
+ void connect(Duration timeout) throws IOException {
+ if (!client.isStarted()) {
+ client.start();
+ }
+ try {
+ session = connect(uri, Collections.emptyList(),
+ future -> notifyCloseListeners(), timeout, MAX_DEPTH);
+ } catch (IOException e) {
+ disconnect(e);
+ throw e;
+ }
+ }
+
+ private ClientSession connect(URIish target, List<URIish> jumps,
+ SshFutureListener<CloseFuture> listener, Duration timeout,
+ int depth) throws IOException {
+ if (--depth < 0) {
+ throw new IOException(
+ format(SshdText.get().proxyJumpAbort, target));
+ }
+ HostConfigEntry hostConfig = getHostConfig(target.getUser(),
+ target.getHost(), target.getPort());
+ String host = hostConfig.getHostName();
+ int port = hostConfig.getPort();
+ List<URIish> hops = determineHops(jumps, hostConfig, target.getHost());
+ ClientSession resultSession = null;
+ ClientSession proxySession = null;
+ PortForwardingTracker portForward = null;
+ AuthenticationLogger authLog = null;
+ try {
+ if (!hops.isEmpty()) {
+ URIish hop = hops.remove(0);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Connecting to jump host {}", hop); //$NON-NLS-1$
+ }
+ proxySession = connect(hop, hops, null, timeout, depth);
+ }
+ AttributeRepository context = null;
+ if (proxySession != null) {
+ SshdSocketAddress remoteAddress = new SshdSocketAddress(host,
+ port);
+ portForward = proxySession.createLocalPortForwardingTracker(
+ SshdSocketAddress.LOCALHOST_ADDRESS, remoteAddress);
+ // We must connect to the locally bound address, not the one
+ // from the host config.
+ context = AttributeRepository.ofKeyValuePair(
+ JGitSshClient.LOCAL_FORWARD_ADDRESS,
+ portForward.getBoundAddress());
+ }
+ int timeoutInSec = OpenSshConfigFile.timeSpec(
+ hostConfig.getProperty(SshConstants.CONNECT_TIMEOUT));
+ resultSession = connect(hostConfig, context,
+ timeoutInSec > 0 ? Duration.ofSeconds(timeoutInSec)
+ : timeout);
+ if (proxySession != null) {
+ final PortForwardingTracker tracker = portForward;
+ final ClientSession pSession = proxySession;
+ resultSession.addCloseFutureListener(future -> {
+ IoUtils.closeQuietly(tracker);
+ String sessionName = pSession.toString();
+ try {
+ pSession.close();
+ } catch (IOException e) {
+ LOG.error(format(
+ SshdText.get().sshProxySessionCloseFailed,
+ sessionName), e);
+ }
+ });
+ portForward = null;
+ proxySession = null;
+ }
+ if (listener != null) {
+ resultSession.addCloseFutureListener(listener);
+ }
+ // Authentication timeout is by default 2 minutes.
+ authLog = new AuthenticationLogger(resultSession);
+ resultSession.auth().verify(resultSession.getAuthTimeout());
+ return resultSession;
+ } catch (IOException e) {
+ close(portForward, e);
+ close(proxySession, e);
+ close(resultSession, e);
+ if (e instanceof SshException && ((SshException) e)
+ .getDisconnectCode() == SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) {
+ String message = format(SshdText.get().loginDenied, host,
+ Integer.toString(port));
+ throw new TransportException(target,
+ withAuthLog(message, authLog), e);
+ } else if (e instanceof SshException && e
+ .getCause() instanceof AuthenticationCanceledException) {
+ String message = e.getCause().getMessage();
+ throw new TransportException(target,
+ withAuthLog(message, authLog), e.getCause());
+ }
+ throw e;
+ } finally {
+ if (authLog != null) {
+ authLog.clear();
+ }
+ }
+ }
+
+ private String withAuthLog(String message, AuthenticationLogger authLog) {
+ if (authLog != null) {
+ String log = String.join(System.lineSeparator(), authLog.getLog());
+ if (!log.isEmpty()) {
+ return message + System.lineSeparator() + log;
+ }
+ }
+ return message;
+ }
+
+ private ClientSession connect(HostConfigEntry config,
+ AttributeRepository context, Duration timeout)
+ throws IOException {
+ ConnectFuture connected = client.connect(config, context, null);
+ long timeoutMillis = timeout.toMillis();
+ if (timeoutMillis <= 0) {
+ connected = connected.verify();
+ } else {
+ connected = connected.verify(timeoutMillis);
+ }
+ return connected.getSession();
+ }
+
+ private void close(Closeable toClose, Throwable error) {
+ if (toClose != null) {
+ try {
+ toClose.close();
+ } catch (IOException e) {
+ error.addSuppressed(e);
+ }
+ }
+ }
+
+ private HostConfigEntry getHostConfig(String username, String host,
+ int port) throws IOException {
+ HostConfigEntry entry = client.getHostConfigEntryResolver()
+ .resolveEffectiveHost(host, port, null, username, null, null);
+ if (entry == null) {
+ if (SshdSocketAddress.isIPv6Address(host)) {
+ return new HostConfigEntry("", host, port, username); //$NON-NLS-1$
+ }
+ return new HostConfigEntry(host, host, port, username);
+ }
+ return entry;
+ }
+
+ private List<URIish> determineHops(List<URIish> currentHops,
+ HostConfigEntry hostConfig, String host) throws IOException {
+ if (currentHops.isEmpty()) {
+ String jumpHosts = hostConfig.getProperty(SshConstants.PROXY_JUMP);
+ if (!StringUtils.isEmptyOrNull(jumpHosts)
+ && !SshConstants.NONE.equals(jumpHosts)) {
+ try {
+ return parseProxyJump(jumpHosts);
+ } catch (URISyntaxException e) {
+ throw new IOException(
+ format(SshdText.get().configInvalidProxyJump, host,
+ jumpHosts),
+ e);
+ }
+ }
+ }
+ return currentHops;
+ }
+
+ private List<URIish> parseProxyJump(String proxyJump)
+ throws URISyntaxException {
+ String[] hops = proxyJump.split(","); //$NON-NLS-1$
+ List<URIish> result = new ArrayList<>();
+ for (String hop : hops) {
+ // There shouldn't be any whitespace, but let's be lenient
+ hop = hop.trim();
+ if (SHORT_SSH_FORMAT.matcher(hop).matches()) {
+ // URIish doesn't understand the short SSH format
+ // user@host:port, only user@host:path
+ hop = SshConstants.SSH_SCHEME + "://" + hop; //$NON-NLS-1$
+ }
+ URIish to = new URIish(hop);
+ if (!SshConstants.SSH_SCHEME.equalsIgnoreCase(to.getScheme())) {
+ throw new URISyntaxException(hop,
+ SshdText.get().configProxyJumpNotSsh);
+ } else if (!StringUtils.isEmptyOrNull(to.getPath())) {
+ throw new URISyntaxException(hop,
+ SshdText.get().configProxyJumpWithPath);
+ }
+ result.add(to);
+ }
+ return result;
+ }
+
+ /**
+ * Adds a {@link SessionCloseListener} to this session. Has no effect if the
+ * given {@code listener} is already registered with this session.
+ *
+ * @param listener
+ * to add
+ */
+ public void addCloseListener(@NonNull SessionCloseListener listener) {
+ listeners.addIfAbsent(listener);
+ }
+
+ /**
+ * Removes the given {@code listener}; has no effect if the listener is not
+ * currently registered with this session.
+ *
+ * @param listener
+ * to remove
+ */
+ public void removeCloseListener(@NonNull SessionCloseListener listener) {
+ listeners.remove(listener);
+ }
+
+ private void notifyCloseListeners() {
+ for (SessionCloseListener l : listeners) {
+ try {
+ l.sessionClosed(this);
+ } catch (RuntimeException e) {
+ LOG.warn(SshdText.get().closeListenerFailed, e);
+ }
+ }
+ }
+
+ @Override
+ public Process exec(String commandName, int timeout) throws IOException {
+ return exec(commandName, Collections.emptyMap(), timeout);
+ }
+
+ @Override
+ public Process exec(String commandName, Map<String, String> environment,
+ int timeout) throws IOException {
+ @SuppressWarnings("resource")
+ ChannelExec exec = session.createExecChannel(commandName, null,
+ environment);
+ if (timeout <= 0) {
+ try {
+ exec.open().verify();
+ } catch (IOException | RuntimeException e) {
+ exec.close(true);
+ throw e;
+ }
+ } else {
+ try {
+ exec.open().verify(TimeUnit.SECONDS.toMillis(timeout));
+ } catch (IOException | RuntimeException e) {
+ exec.close(true);
+ throw new IOException(format(SshdText.get().sshCommandTimeout,
+ commandName, Integer.valueOf(timeout)), e);
+ }
+ }
+ return new SshdExecProcess(exec, commandName);
+ }
+
+ /**
+ * Obtain an {@link FtpChannel} to perform SFTP operations in this
+ * {@link SshdSession}.
+ */
+ @Override
+ @NonNull
+ public FtpChannel getFtpChannel() {
+ return new SshdFtpChannel();
+ }
+
+ @Override
+ public void disconnect() {
+ disconnect(null);
+ }
+
+ private void disconnect(Throwable reason) {
+ try {
+ if (session != null) {
+ session.close();
+ session = null;
+ }
+ } catch (IOException e) {
+ if (reason != null) {
+ reason.addSuppressed(e);
+ } else {
+ LOG.error(SshdText.get().sessionCloseFailed, e);
+ }
+ } finally {
+ client.stop();
+ client = null;
+ }
+ }
+
+ private static class SshdExecProcess extends Process {
+
+ private final ChannelExec channel;
+
+ private final String commandName;
+
+ public SshdExecProcess(ChannelExec channel, String commandName) {
+ this.channel = channel;
+ this.commandName = commandName;
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return channel.getInvertedIn();
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return channel.getInvertedOut();
+ }
+
+ @Override
+ public InputStream getErrorStream() {
+ return channel.getInvertedErr();
+ }
+
+ @Override
+ public int waitFor() throws InterruptedException {
+ if (waitFor(-1L, TimeUnit.MILLISECONDS)) {
+ return exitValue();
+ }
+ return -1;
+ }
+
+ @Override
+ public boolean waitFor(long timeout, TimeUnit unit)
+ throws InterruptedException {
+ long millis = timeout >= 0 ? unit.toMillis(timeout) : -1L;
+ return channel
+ .waitFor(EnumSet.of(ClientChannelEvent.CLOSED), millis)
+ .contains(ClientChannelEvent.CLOSED);
+ }
+
+ @Override
+ public int exitValue() {
+ Integer exitCode = channel.getExitStatus();
+ if (exitCode == null) {
+ throw new IllegalThreadStateException(
+ format(SshdText.get().sshProcessStillRunning,
+ commandName));
+ }
+ return exitCode.intValue();
+ }
+
+ @Override
+ public void destroy() {
+ if (channel.isOpen()) {
+ channel.close(false);
+ }
+ }
+ }
+
+ private class SshdFtpChannel implements FtpChannel {
+
+ private SftpClient ftp;
+
+ /** Current working directory. */
+ private String cwd = ""; //$NON-NLS-1$
+
+ @Override
+ public void connect(int timeout, TimeUnit unit) throws IOException {
+ if (timeout <= 0) {
+ // This timeout must not be null!
+ SFTP_CHANNEL_OPEN_TIMEOUT.set(session,
+ Duration.ofMillis(Long.MAX_VALUE));
+ } else {
+ SFTP_CHANNEL_OPEN_TIMEOUT.set(session,
+ Duration.ofMillis(unit.toMillis(timeout)));
+ }
+ ftp = SftpClientFactory.instance().createSftpClient(session);
+ try {
+ cd(cwd);
+ } catch (IOException e) {
+ ftp.close();
+ }
+ }
+
+ @Override
+ public void disconnect() {
+ try {
+ ftp.close();
+ } catch (IOException e) {
+ LOG.error(SshdText.get().ftpCloseFailed, e);
+ }
+ }
+
+ @Override
+ public boolean isConnected() {
+ return session.isAuthenticated() && ftp.isOpen();
+ }
+
+ private String absolute(String path) {
+ if (path.isEmpty()) {
+ return cwd;
+ }
+ // Note: there is no path injection vulnerability here. If
+ // path has too many ".." components, we rely on the server
+ // catching it and returning an error.
+ if (path.charAt(0) != '/') {
+ if (cwd.charAt(cwd.length() - 1) == '/') {
+ return cwd + path;
+ }
+ return cwd + '/' + path;
+ }
+ return path;
+ }
+
+ private <T> T map(IOFunction<Void, T> op) throws IOException {
+ try {
+ return op.apply(null);
+ } catch (IOException e) {
+ if (e instanceof SftpException) {
+ throw new FtpChannel.FtpException(e.getLocalizedMessage(),
+ ((SftpException) e).getStatus(), e);
+ }
+ throw e;
+ }
+ }
+
+ @Override
+ public void cd(String path) throws IOException {
+ cwd = map(x -> ftp.canonicalPath(absolute(path)));
+ if (cwd.isEmpty()) {
+ cwd += '/';
+ }
+ }
+
+ @Override
+ public String pwd() throws IOException {
+ return cwd;
+ }
+
+ @Override
+ public Collection<DirEntry> ls(String path) throws IOException {
+ return map(x -> {
+ List<DirEntry> result = new ArrayList<>();
+ for (SftpClient.DirEntry remote : ftp.readDir(absolute(path))) {
+ result.add(new DirEntry() {
+
+ @Override
+ public String getFilename() {
+ return remote.getFilename();
+ }
+
+ @Override
+ public long getModifiedTime() {
+ return remote.getAttributes().getModifyTime()
+ .toMillis();
+ }
+
+ @Override
+ public boolean isDirectory() {
+ return remote.getAttributes().isDirectory();
+ }
+
+ });
+ }
+ return result;
+ });
+ }
+
+ @Override
+ public void rmdir(String path) throws IOException {
+ map(x -> {
+ ftp.rmdir(absolute(path));
+ return null;
+ });
+
+ }
+
+ @Override
+ public void mkdir(String path) throws IOException {
+ map(x -> {
+ ftp.mkdir(absolute(path));
+ return null;
+ });
+ }
+
+ @Override
+ public InputStream get(String path) throws IOException {
+ return map(x -> ftp.read(absolute(path)));
+ }
+
+ @Override
+ public OutputStream put(String path) throws IOException {
+ return map(x -> ftp.write(absolute(path)));
+ }
+
+ @Override
+ public void rm(String path) throws IOException {
+ map(x -> {
+ ftp.remove(absolute(path));
+ return null;
+ });
+ }
+
+ @Override
+ public void rename(String from, String to) throws IOException {
+ map(x -> {
+ String src = absolute(from);
+ String dest = absolute(to);
+ try {
+ ftp.rename(src, dest, CopyMode.Atomic, CopyMode.Overwrite);
+ } catch (UnsupportedOperationException e) {
+ // Older server cannot do POSIX rename...
+ if (!src.equals(dest)) {
+ delete(dest);
+ ftp.rename(src, dest);
+ }
+ }
+ return null;
+ });
+ }
+ }
+}
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
new file mode 100644
index 0000000000..4a2eb9c3dd
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
@@ -0,0 +1,674 @@
+/*
+ * Copyright (C) 2018, 2024 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.transport.sshd;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.apache.sshd.client.ClientBuilder;
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.auth.UserAuthFactory;
+import org.apache.sshd.client.auth.keyboard.UserAuthKeyboardInteractiveFactory;
+import org.apache.sshd.client.auth.password.UserAuthPasswordFactory;
+import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
+import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.compression.BuiltinCompressions;
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.apache.sshd.common.config.keys.loader.openssh.kdf.BCryptKdfOptions;
+import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
+import org.apache.sshd.common.signature.BuiltinSignatures;
+import org.apache.sshd.common.signature.Signature;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.errors.TransportException;
+import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
+import org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider;
+import org.eclipse.jgit.internal.transport.sshd.GssApiWithMicAuthFactory;
+import org.eclipse.jgit.internal.transport.sshd.JGitPublicKeyAuthFactory;
+import org.eclipse.jgit.internal.transport.sshd.JGitServerKeyVerifier;
+import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
+import org.eclipse.jgit.internal.transport.sshd.JGitSshConfig;
+import org.eclipse.jgit.internal.transport.sshd.JGitUserInteraction;
+import org.eclipse.jgit.internal.transport.sshd.OpenSshServerKeyDatabase;
+import org.eclipse.jgit.internal.transport.sshd.PasswordProviderWrapper;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+import org.eclipse.jgit.internal.transport.sshd.agent.JGitSshAgentFactory;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.SshConfigStore;
+import org.eclipse.jgit.transport.SshConstants;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.sshd.agent.Connector;
+import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory;
+import org.eclipse.jgit.util.FS;
+
+/**
+ * A {@link SshSessionFactory} that uses Apache MINA sshd. Classes from Apache
+ * MINA sshd are kept private to avoid API evolution problems when Apache MINA
+ * sshd interfaces change.
+ *
+ * @since 5.2
+ */
+public class SshdSessionFactory extends SshSessionFactory implements Closeable {
+
+ private static final String MINA_SSHD = "mina-sshd"; //$NON-NLS-1$
+
+ private final AtomicBoolean closing = new AtomicBoolean();
+
+ private final Set<SshdSession> sessions = new HashSet<>();
+
+ private final Map<Tuple, HostConfigEntryResolver> defaultHostConfigEntryResolver = new ConcurrentHashMap<>();
+
+ private final Map<Tuple, ServerKeyDatabase> defaultServerKeyDatabase = new ConcurrentHashMap<>();
+
+ private final Map<Tuple, Iterable<KeyPair>> defaultKeys = new ConcurrentHashMap<>();
+
+ private final KeyCache keyCache;
+
+ private final ProxyDataFactory proxies;
+
+ private File sshDirectory;
+
+ private File homeDirectory;
+
+ /**
+ * Creates a new {@link SshdSessionFactory} without key cache and a
+ * {@link DefaultProxyDataFactory}.
+ */
+ public SshdSessionFactory() {
+ this(null, new DefaultProxyDataFactory());
+ }
+
+ /**
+ * Creates a new {@link SshdSessionFactory} using the given {@link KeyCache}
+ * and {@link ProxyDataFactory}. The {@code keyCache} is used for all
+ * sessions created through this session factory; cached keys are destroyed
+ * when the session factory is {@link #close() closed}.
+ * <p>
+ * Caching ssh keys in memory for an extended period of time is generally
+ * considered bad practice, but there may be circumstances where using a
+ * {@link KeyCache} is still the right choice, for instance to avoid that a
+ * user gets prompted several times for the same password for the same key.
+ * In general, however, it is preferable <em>not</em> to use a key cache but
+ * to use a {@link #createKeyPasswordProvider(CredentialsProvider)
+ * KeyPasswordProvider} that has access to some secure storage and can save
+ * and retrieve passwords from there without user interaction. Another
+ * approach is to use an SSH agent.
+ * </p>
+ * <p>
+ * Note that the underlying ssh library (Apache MINA sshd) may or may not
+ * keep ssh keys in memory for unspecified periods of time irrespective of
+ * the use of a {@link KeyCache}.
+ * </p>
+ * <p>
+ * By default, the factory uses the {@link java.util.ServiceLoader} to find
+ * a {@link ConnectorFactory} for creating a {@link Connector} to connect to
+ * a running SSH agent. If it finds one, the SSH agent is used in publickey
+ * authentication. If there is none, no SSH agent will ever be contacted.
+ * Note that one can define {@code IdentitiesOnly yes} for a host entry in
+ * the {@code ~/.ssh/config} file to bypass the SSH agent in any case.
+ * </p>
+ *
+ * @param keyCache
+ * {@link KeyCache} to use for caching ssh keys, or {@code null}
+ * to not use a key cache
+ * @param proxies
+ * {@link ProxyDataFactory} to use, or {@code null} to not use a
+ * proxy database (in which case connections through proxies will
+ * not be possible)
+ */
+ public SshdSessionFactory(KeyCache keyCache, ProxyDataFactory proxies) {
+ super();
+ this.keyCache = keyCache;
+ this.proxies = proxies;
+ // sshd limits the number of BCrypt KDF rounds to 255 by default.
+ // Decrypting such a key takes about two seconds on my machine.
+ // I consider this limit too low. The time increases linearly with the
+ // number of rounds.
+ BCryptKdfOptions.setMaxAllowedRounds(16384);
+ }
+
+ @Override
+ public String getType() {
+ return MINA_SSHD;
+ }
+
+ /** A simple general map key. */
+ private static final class Tuple {
+ private Object[] objects;
+
+ public Tuple(Object[] objects) {
+ this.objects = objects;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (obj != null && obj.getClass() == Tuple.class) {
+ Tuple other = (Tuple) obj;
+ return Arrays.equals(objects, other.objects);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(objects);
+ }
+ }
+
+ // We can't really use a single client. Clients need to be stopped
+ // properly, and we don't really know when to do that. Instead we use
+ // a dedicated SshClient instance per session. We need a bit of caching to
+ // avoid re-loading the ssh config and keys repeatedly.
+
+ @Override
+ public SshdSession getSession(URIish uri,
+ CredentialsProvider credentialsProvider, FS fs, int tms)
+ throws TransportException {
+ SshdSession session = null;
+ try {
+ session = new SshdSession(uri, () -> {
+ File home = getHomeDirectory();
+ if (home == null) {
+ // Always use the detected filesystem for the user home!
+ // It makes no sense to have different "user home"
+ // directories depending on what file system a repository
+ // is.
+ home = FS.DETECTED.userHome();
+ }
+ File sshDir = getSshDirectory();
+ if (sshDir == null) {
+ sshDir = new File(home, SshConstants.SSH_DIR);
+ }
+ HostConfigEntryResolver configFile = getHostConfigEntryResolver(
+ home, sshDir);
+ KeyIdentityProvider defaultKeysProvider = toKeyIdentityProvider(
+ getDefaultKeys(sshDir));
+ Supplier<KeyPasswordProvider> keyPasswordProvider = newKeyPasswordProvider(
+ credentialsProvider);
+ SshClient client = ClientBuilder.builder()
+ .factory(JGitSshClient::new)
+ .filePasswordProvider(createFilePasswordProvider(
+ keyPasswordProvider))
+ .hostConfigEntryResolver(configFile)
+ .serverKeyVerifier(new JGitServerKeyVerifier(
+ getServerKeyDatabase(home, sshDir)))
+ .signatureFactories(getSignatureFactories())
+ .compressionFactories(
+ new ArrayList<>(BuiltinCompressions.VALUES))
+ .build();
+ client.setUserInteraction(
+ new JGitUserInteraction(credentialsProvider));
+ client.setUserAuthFactories(getUserAuthFactories());
+ client.setKeyIdentityProvider(defaultKeysProvider);
+ ConnectorFactory connectors = getConnectorFactory();
+ if (connectors != null) {
+ client.setAgentFactory(
+ new JGitSshAgentFactory(connectors, home));
+ }
+ // JGit-specific things:
+ JGitSshClient jgitClient = (JGitSshClient) client;
+ jgitClient.setKeyCache(getKeyCache());
+ jgitClient.setCredentialsProvider(credentialsProvider);
+ jgitClient.setProxyDatabase(proxies);
+ jgitClient.setKeyPasswordProviderFactory(keyPasswordProvider);
+ String defaultAuths = getDefaultPreferredAuthentications();
+ if (defaultAuths != null) {
+ jgitClient.setAttribute(
+ JGitSshClient.PREFERRED_AUTHENTICATIONS,
+ defaultAuths);
+ }
+ if (home != null) {
+ try {
+ jgitClient.setAttribute(JGitSshClient.HOME_DIRECTORY,
+ home.getAbsoluteFile().toPath());
+ } catch (SecurityException | InvalidPathException e) {
+ // Ignore
+ }
+ }
+ // Other things?
+ return client;
+ });
+ session.addCloseListener(s -> unregister(s));
+ register(session);
+ session.connect(Duration.ofMillis(tms));
+ return session;
+ } catch (Exception e) {
+ unregister(session);
+ if (e instanceof TransportException) {
+ throw (TransportException) e;
+ }
+ throw new TransportException(uri, e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void close() {
+ closing.set(true);
+ boolean cleanKeys = false;
+ synchronized (this) {
+ cleanKeys = sessions.isEmpty();
+ }
+ if (cleanKeys) {
+ KeyCache cache = getKeyCache();
+ if (cache != null) {
+ cache.close();
+ }
+ }
+ }
+
+ private void register(SshdSession newSession) throws IOException {
+ if (newSession == null) {
+ return;
+ }
+ if (closing.get()) {
+ throw new IOException(SshdText.get().sshClosingDown);
+ }
+ synchronized (this) {
+ sessions.add(newSession);
+ }
+ }
+
+ private void unregister(SshdSession oldSession) {
+ boolean cleanKeys = false;
+ synchronized (this) {
+ sessions.remove(oldSession);
+ cleanKeys = closing.get() && sessions.isEmpty();
+ }
+ if (cleanKeys) {
+ KeyCache cache = getKeyCache();
+ if (cache != null) {
+ cache.close();
+ }
+ }
+ }
+
+ /**
+ * Set a global directory to use as the user's home directory
+ *
+ * @param homeDir
+ * to use
+ */
+ public void setHomeDirectory(@NonNull File homeDir) {
+ if (homeDir.isAbsolute()) {
+ homeDirectory = homeDir;
+ } else {
+ homeDirectory = homeDir.getAbsoluteFile();
+ }
+ }
+
+ /**
+ * Retrieves the global user home directory
+ *
+ * @return the directory, or {@code null} if not set
+ */
+ public File getHomeDirectory() {
+ return homeDirectory;
+ }
+
+ /**
+ * Set a global directory to use as the .ssh directory
+ *
+ * @param sshDir
+ * to use
+ */
+ public void setSshDirectory(@NonNull File sshDir) {
+ if (sshDir.isAbsolute()) {
+ sshDirectory = sshDir;
+ } else {
+ sshDirectory = sshDir.getAbsoluteFile();
+ }
+ }
+
+ /**
+ * Retrieves the global .ssh directory
+ *
+ * @return the directory, or {@code null} if not set
+ */
+ public File getSshDirectory() {
+ return sshDirectory;
+ }
+
+ /**
+ * Obtain a {@link HostConfigEntryResolver} to read the ssh config file and
+ * to determine host entries for connections.
+ *
+ * @param homeDir
+ * home directory to use for ~ replacement
+ * @param sshDir
+ * to use for looking for the config file
+ * @return the resolver
+ */
+ @NonNull
+ private HostConfigEntryResolver getHostConfigEntryResolver(
+ @NonNull File homeDir, @NonNull File sshDir) {
+ return defaultHostConfigEntryResolver.computeIfAbsent(
+ new Tuple(new Object[] { homeDir, sshDir }),
+ t -> new JGitSshConfig(createSshConfigStore(homeDir,
+ getSshConfig(sshDir), getLocalUserName())));
+ }
+
+ /**
+ * Determines the ssh config file. The default implementation returns
+ * ~/.ssh/config. If the file does not exist and is created later it will be
+ * picked up. To not use a config file at all, return {@code null}.
+ *
+ * @param sshDir
+ * representing ~/.ssh/
+ * @return the file (need not exist), or {@code null} if no config file
+ * shall be used
+ * @since 5.5
+ */
+ protected File getSshConfig(@NonNull File sshDir) {
+ return new File(sshDir, SshConstants.CONFIG);
+ }
+
+ /**
+ * 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.
+ *
+ * @param homeDir
+ * may be used for ~-replacements by the returned config store
+ * @param configFile
+ * to use, or {@code null} if none
+ * @param localUserName
+ * user name of the current user on the local OS
+ * @return A {@link SshConfigStore}, or {@code null} if none is to be used
+ *
+ * @since 5.8
+ */
+ protected SshConfigStore createSshConfigStore(@NonNull File homeDir,
+ File configFile, String localUserName) {
+ return configFile == null ? null
+ : new OpenSshConfigFile(homeDir, configFile, localUserName);
+ }
+
+ /**
+ * Obtains a {@link ServerKeyDatabase} to verify server host keys. The
+ * default implementation returns a {@link ServerKeyDatabase} that
+ * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and
+ * {@code ~/.ssh/known_hosts2} as well as any files configured via the
+ * {@code UserKnownHostsFile} option in the ssh config file.
+ *
+ * @param homeDir
+ * home directory to use for ~ replacement
+ * @param sshDir
+ * representing ~/.ssh/
+ * @return the {@link ServerKeyDatabase}
+ * @since 5.5
+ */
+ @NonNull
+ protected ServerKeyDatabase getServerKeyDatabase(@NonNull File homeDir,
+ @NonNull File sshDir) {
+ return defaultServerKeyDatabase.computeIfAbsent(
+ new Tuple(new Object[] { homeDir, sshDir }),
+ t -> createServerKeyDatabase(homeDir, sshDir));
+
+ }
+
+ /**
+ * Creates a {@link ServerKeyDatabase} to verify server host keys. The
+ * default implementation returns a {@link ServerKeyDatabase} that
+ * recognizes the two openssh standard files {@code ~/.ssh/known_hosts} and
+ * {@code ~/.ssh/known_hosts2} as well as any files configured via the
+ * {@code UserKnownHostsFile} option in the ssh config file.
+ *
+ * @param homeDir
+ * home directory to use for ~ replacement
+ * @param sshDir
+ * representing ~/.ssh/
+ * @return the {@link ServerKeyDatabase}
+ * @since 5.8
+ */
+ @NonNull
+ protected ServerKeyDatabase createServerKeyDatabase(@NonNull File homeDir,
+ @NonNull File sshDir) {
+ return new OpenSshServerKeyDatabase(true,
+ getDefaultKnownHostsFiles(sshDir));
+ }
+
+ /**
+ * Gets a {@link ConnectorFactory}. If this returns {@code null}, SSH agents
+ * are not supported.
+ * <p>
+ * The default implementation uses {@link ConnectorFactory#getDefault()}
+ * </p>
+ *
+ * @return the factory, or {@code null} if no SSH agent support is desired
+ * @since 6.0
+ */
+ protected ConnectorFactory getConnectorFactory() {
+ return ConnectorFactory.getDefault();
+ }
+
+ /**
+ * Gets the list of default user known hosts files. The default returns
+ * ~/.ssh/known_hosts and ~/.ssh/known_hosts2. The ssh config
+ * {@code UserKnownHostsFile} overrides this default.
+ *
+ * @param sshDir
+ * directory containing ssh configurations
+ * @return the possibly empty list of default known host file paths.
+ */
+ @NonNull
+ protected List<Path> getDefaultKnownHostsFiles(@NonNull File sshDir) {
+ return Arrays.asList(sshDir.toPath().resolve(SshConstants.KNOWN_HOSTS),
+ sshDir.toPath().resolve(SshConstants.KNOWN_HOSTS + '2'));
+ }
+
+ /**
+ * Determines the default keys. The default implementation will lazy load
+ * the {@link #getDefaultIdentities(File) default identity files}.
+ * <p>
+ * Subclasses may override and return an {@link Iterable} of whatever keys
+ * are appropriate. If the returned iterable lazily loads keys, it should be
+ * an instance of
+ * {@link org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider
+ * AbstractResourceKeyPairProvider} so that the session can later pass it
+ * the {@link #createKeyPasswordProvider(CredentialsProvider) password
+ * provider} wrapped as a {@link FilePasswordProvider} via
+ * {@link org.apache.sshd.common.keyprovider.AbstractResourceKeyPairProvider#setPasswordFinder(FilePasswordProvider)
+ * AbstractResourceKeyPairProvider#setPasswordFinder(FilePasswordProvider)}
+ * so that encrypted, password-protected keys can be loaded.
+ * </p>
+ * <p>
+ * The default implementation uses exactly this mechanism; class
+ * {@link CachingKeyPairProvider} may serve as a model for a customized
+ * lazy-loading {@link Iterable} implementation
+ * </p>
+ * <p>
+ * If the {@link Iterable} returned has the keys already pre-loaded or
+ * otherwise doesn't need to decrypt encrypted keys, it can be any
+ * {@link Iterable}, for instance a simple {@link java.util.List List}.
+ * </p>
+ *
+ * @param sshDir
+ * to look in for keys
+ * @return an {@link Iterable} over the default keys
+ * @since 5.3
+ */
+ @NonNull
+ protected Iterable<KeyPair> getDefaultKeys(@NonNull File sshDir) {
+ List<Path> defaultIdentities = getDefaultIdentities(sshDir);
+ return defaultKeys.computeIfAbsent(
+ new Tuple(defaultIdentities.toArray(new Path[0])),
+ t -> new CachingKeyPairProvider(defaultIdentities,
+ getKeyCache()));
+ }
+
+ /**
+ * Converts an {@link Iterable} of {link KeyPair}s into a
+ * {@link KeyIdentityProvider}.
+ *
+ * @param keys
+ * to provide via the returned {@link KeyIdentityProvider}
+ * @return a {@link KeyIdentityProvider} that provides the given
+ * {@code keys}
+ */
+ private KeyIdentityProvider toKeyIdentityProvider(Iterable<KeyPair> keys) {
+ if (keys instanceof KeyIdentityProvider) {
+ return (KeyIdentityProvider) keys;
+ }
+ return (session) -> keys;
+ }
+
+ /**
+ * Gets a list of default identities, i.e., private key files that shall
+ * always be tried for public key authentication. Typically those are
+ * ~/.ssh/id_dsa, ~/.ssh/id_rsa, and so on. The default implementation
+ * returns the files defined in {@link SshConstants#DEFAULT_IDENTITIES}.
+ *
+ * @param sshDir
+ * the directory that represents ~/.ssh/
+ * @return a possibly empty list of paths containing default identities
+ * (private keys)
+ */
+ @NonNull
+ protected List<Path> getDefaultIdentities(@NonNull File sshDir) {
+ return Arrays
+ .asList(SshConstants.DEFAULT_IDENTITIES).stream()
+ .map(s -> new File(sshDir, s).toPath()).filter(Files::exists)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Obtains the {@link KeyCache} to use to cache loaded keys.
+ *
+ * @return the {@link KeyCache}, or {@code null} if none.
+ */
+ protected final KeyCache getKeyCache() {
+ return keyCache;
+ }
+
+ /**
+ * Creates a {@link KeyPasswordProvider} for a new session.
+ *
+ * @param provider
+ * the {@link CredentialsProvider} to delegate to for user
+ * interactions
+ * @return a new {@link KeyPasswordProvider}, or {@code null} to use the
+ * global {@link KeyPasswordProviderFactory}
+ */
+ protected KeyPasswordProvider createKeyPasswordProvider(
+ CredentialsProvider provider) {
+ return null;
+ }
+
+ private Supplier<KeyPasswordProvider> newKeyPasswordProvider(
+ CredentialsProvider credentials) {
+ return () -> {
+ KeyPasswordProvider provider = createKeyPasswordProvider(
+ credentials);
+ if (provider != null) {
+ return provider;
+ }
+ return KeyPasswordProviderFactory.getInstance().apply(credentials);
+ };
+ }
+
+ /**
+ * Creates a {@link FilePasswordProvider} for a new session.
+ *
+ * @param providerFactory
+ * providing the {@link KeyPasswordProvider} to delegate to
+ * @return a new {@link FilePasswordProvider}
+ */
+ @NonNull
+ private FilePasswordProvider createFilePasswordProvider(
+ Supplier<KeyPasswordProvider> providerFactory) {
+ return new PasswordProviderWrapper(providerFactory);
+ }
+
+ /**
+ * Gets the user authentication mechanisms (or rather, factories for them).
+ * By default this returns gssapi-with-mic, public-key, password, and
+ * keyboard-interactive, in that order. The order is only significant if the
+ * ssh config does <em>not</em> set {@code PreferredAuthentications}; if it
+ * is set, the order defined there will be taken.
+ *
+ * @return the non-empty list of factories.
+ */
+ @NonNull
+ private List<UserAuthFactory> getUserAuthFactories() {
+ // About the order of password and keyboard-interactive, see upstream
+ // bug https://issues.apache.org/jira/projects/SSHD/issues/SSHD-866 .
+ // Password auth doesn't have this problem.
+ return Collections.unmodifiableList(
+ Arrays.asList(GssApiWithMicAuthFactory.INSTANCE,
+ JGitPublicKeyAuthFactory.FACTORY,
+ UserAuthPasswordFactory.INSTANCE,
+ UserAuthKeyboardInteractiveFactory.INSTANCE));
+ }
+
+ /**
+ * Gets the list of default preferred authentication mechanisms. If
+ * {@code null} is returned the openssh default list will be in effect. If
+ * the ssh config defines {@code PreferredAuthentications} the value from
+ * the ssh config takes precedence.
+ *
+ * @return a comma-separated list of mechanism names, or {@code null} if
+ * none
+ */
+ protected String getDefaultPreferredAuthentications() {
+ return null;
+ }
+
+ /**
+ * Apache MINA sshd 2.6.0 has removed DSA, DSA_CERT and RSA_CERT. We have to
+ * set it up explicitly to still allow users to connect with DSA keys.
+ *
+ * @return a list of supported signature factories
+ */
+ @SuppressWarnings("deprecation")
+ private static List<NamedFactory<Signature>> getSignatureFactories() {
+ // @formatter:off
+ return Arrays.asList(
+ BuiltinSignatures.nistp256_cert,
+ BuiltinSignatures.nistp384_cert,
+ BuiltinSignatures.nistp521_cert,
+ BuiltinSignatures.ed25519_cert,
+ BuiltinSignatures.rsaSHA512_cert,
+ BuiltinSignatures.rsaSHA256_cert,
+ BuiltinSignatures.rsa_cert,
+ BuiltinSignatures.nistp256,
+ BuiltinSignatures.nistp384,
+ BuiltinSignatures.nistp521,
+ BuiltinSignatures.ed25519,
+ BuiltinSignatures.sk_ecdsa_sha2_nistp256,
+ BuiltinSignatures.sk_ssh_ed25519,
+ BuiltinSignatures.rsaSHA512,
+ BuiltinSignatures.rsaSHA256,
+ BuiltinSignatures.rsa,
+ BuiltinSignatures.dsa_cert,
+ BuiltinSignatures.dsa);
+ // @formatter:on
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java
new file mode 100644
index 0000000000..7ed9b5ea3b
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactoryBuilder.java
@@ -0,0 +1,444 @@
+/*
+ * Copyright (C) 2020, 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.transport.sshd;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.SshConfigStore;
+import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory;
+import org.eclipse.jgit.util.StringUtils;
+
+/**
+ * A builder API to configure {@link SshdSessionFactory SshdSessionFactories}.
+ *
+ * @since 5.8
+ */
+public final class SshdSessionFactoryBuilder {
+
+ private final State state = new State();
+
+ /**
+ * Sets the {@link ProxyDataFactory} to use for {@link SshdSessionFactory
+ * SshdSessionFactories} created by {@link #build(KeyCache)}.
+ *
+ * @param proxyDataFactory
+ * to use
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setProxyDataFactory(
+ ProxyDataFactory proxyDataFactory) {
+ this.state.proxyDataFactory = proxyDataFactory;
+ return this;
+ }
+
+ /**
+ * Sets the home directory to use for {@link SshdSessionFactory
+ * SshdSessionFactories} created by {@link #build(KeyCache)}.
+ *
+ * @param homeDirectory
+ * to use; may be {@code null}, in which case the home directory
+ * as defined by {@link org.eclipse.jgit.util.FS#userHome()
+ * FS.userHome()} is assumed
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setHomeDirectory(File homeDirectory) {
+ this.state.homeDirectory = homeDirectory;
+ return this;
+ }
+
+ /**
+ * Sets the SSH directory to use for {@link SshdSessionFactory
+ * SshdSessionFactories} created by {@link #build(KeyCache)}.
+ *
+ * @param sshDirectory
+ * to use; may be {@code null}, in which case ".ssh" under the
+ * {@link #setHomeDirectory(File) home directory} is assumed
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setSshDirectory(File sshDirectory) {
+ this.state.sshDirectory = sshDirectory;
+ return this;
+ }
+
+ /**
+ * Sets the default preferred authentication mechanisms to use for
+ * {@link SshdSessionFactory SshdSessionFactories} created by
+ * {@link #build(KeyCache)}.
+ *
+ * @param authentications
+ * comma-separated list of authentication mechanism names; if
+ * {@code null} or empty, the default as specified by
+ * {@link SshdSessionFactory#getDefaultPreferredAuthentications()}
+ * will be used
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setPreferredAuthentications(
+ String authentications) {
+ this.state.preferredAuthentications = authentications;
+ return this;
+ }
+
+ /**
+ * Sets a function that returns the SSH config file, given the SSH
+ * directory. The function may return {@code null}, in which case no SSH
+ * config file will be used. If a non-null file is returned, it will be used
+ * when it exists. If no supplier has been set, or the supplier has been set
+ * explicitly to {@code null}, by default a file named
+ * {@link org.eclipse.jgit.transport.SshConstants#CONFIG
+ * SshConstants.CONFIG} in the {@link #setSshDirectory(File) SSH directory}
+ * is used.
+ *
+ * @param supplier
+ * returning a {@link File} for the SSH config file to use, or
+ * returning {@code null} if no config file is to be used
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setConfigFile(
+ Function<File, File> supplier) {
+ this.state.configFileFinder = supplier;
+ return this;
+ }
+
+ /**
+ * A factory interface for creating a {@link SshConfigStore}.
+ */
+ @FunctionalInterface
+ public interface ConfigStoreFactory {
+
+ /**
+ * Creates a {@link SshConfigStore}. May return {@code null} if none is
+ * to be used.
+ *
+ * @param homeDir
+ * to use for ~-replacements
+ * @param configFile
+ * to use, may be {@code null} if none
+ * @param localUserName
+ * name of the current user in the local OS
+ * @return the {@link SshConfigStore}, or {@code null} if none is to be
+ * used
+ */
+ SshConfigStore create(@NonNull File homeDir, File configFile,
+ String localUserName);
+ }
+
+ /**
+ * Sets a factory for the {@link SshConfigStore} to use. If not set or
+ * explicitly set to {@code null}, the default as specified by
+ * {@link SshdSessionFactory#createSshConfigStore(File, File, String)} is
+ * used.
+ *
+ * @param factory
+ * to set
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setConfigStoreFactory(
+ ConfigStoreFactory factory) {
+ this.state.configFactory = factory;
+ return this;
+ }
+
+ /**
+ * Sets a function that returns the default known hosts files, given the SSH
+ * directory. If not set or explicitly set to {@code null}, the defaults as
+ * specified by {@link SshdSessionFactory#getDefaultKnownHostsFiles(File)}
+ * are used.
+ *
+ * @param supplier
+ * to get the default known hosts files
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setDefaultKnownHostsFiles(
+ Function<File, List<Path>> supplier) {
+ this.state.knownHostsFileFinder = supplier;
+ return this;
+ }
+
+ /**
+ * Sets a function that returns the default private key files, given the SSH
+ * directory. If not set or explicitly set to {@code null}, the defaults as
+ * specified by {@link SshdSessionFactory#getDefaultIdentities(File)} are
+ * used.
+ *
+ * @param supplier
+ * to get the default private key files
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setDefaultIdentities(
+ Function<File, List<Path>> supplier) {
+ this.state.defaultKeyFileFinder = supplier;
+ return this;
+ }
+
+ /**
+ * Sets a function that returns the default private keys, given the SSH
+ * directory. If not set or explicitly set to {@code null}, the defaults as
+ * specified by {@link SshdSessionFactory#getDefaultKeys(File)} are used.
+ *
+ * @param provider
+ * to get the default private key files
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setDefaultKeysProvider(
+ Function<File, Iterable<KeyPair>> provider) {
+ this.state.defaultKeysProvider = provider;
+ return this;
+ }
+
+ /**
+ * Sets a factory function to create a {@link KeyPasswordProvider}. If not
+ * set or explicitly set to {@code null}, or if the factory returns
+ * {@code null}, the default as specified by
+ * {@link SshdSessionFactory#createKeyPasswordProvider(CredentialsProvider)}
+ * is used.
+ *
+ * @param factory
+ * to create a {@link KeyPasswordProvider}
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setKeyPasswordProvider(
+ Function<CredentialsProvider, KeyPasswordProvider> factory) {
+ this.state.passphraseProviderFactory = factory;
+ return this;
+ }
+
+ /**
+ * Sets a function that creates a new {@link ServerKeyDatabase}, given the
+ * SSH and home directory. If not set or explicitly set to {@code null}, or
+ * if the {@code factory} returns {@code null}, the default as specified by
+ * {@link SshdSessionFactory#createServerKeyDatabase(File, File)} is used.
+ *
+ * @param factory
+ * to create a {@link ServerKeyDatabase}
+ * @return this {@link SshdSessionFactoryBuilder}
+ */
+ public SshdSessionFactoryBuilder setServerKeyDatabase(
+ BiFunction<File, File, ServerKeyDatabase> factory) {
+ this.state.serverKeyDatabaseCreator = factory;
+ return this;
+ }
+
+ /**
+ * Sets an explicit {@link ConnectorFactory}. If {@code null}, there will be
+ * no support for SSH agents.
+ * <p>
+ * If not set, the created {@link SshdSessionFactory} will use the
+ * {@link java.util.ServiceLoader} to find an {@link ConnectorFactory}.
+ * </p>
+ *
+ * @param factory
+ * {@link ConnectorFactory} to use
+ * @return this {@link SshdSessionFactoryBuilder}
+ * @since 6.0
+ */
+ public SshdSessionFactoryBuilder setConnectorFactory(
+ ConnectorFactory factory) {
+ this.state.connectorFactory = factory;
+ this.state.connectorFactorySet = true;
+ return this;
+ }
+
+ /**
+ * Removes a previously set {@link ConnectorFactory}. The created
+ * {@link SshdSessionFactory} will use the {@link java.util.ServiceLoader}
+ * to find an {@link ConnectorFactory}. This is also the default if
+ * {@link #setConnectorFactory(ConnectorFactory)} isn't called at all.
+ *
+ * @return this {@link SshdSessionFactoryBuilder}
+ * @since 6.0
+ */
+ public SshdSessionFactoryBuilder withDefaultConnectorFactory() {
+ this.state.connectorFactory = null;
+ this.state.connectorFactorySet = false;
+ return this;
+ }
+
+ /**
+ * Builds a {@link SshdSessionFactory} as configured, using the given
+ * {@link KeyCache} for caching keys.
+ * <p>
+ * Different {@link SshdSessionFactory SshdSessionFactories} should
+ * <em>not</em> share the same {@link KeyCache} since the cache is
+ * invalidated when the factory itself or when the last {@link SshdSession}
+ * created from the factory is closed.
+ * </p>
+ *
+ * @param cache
+ * to use for caching ssh keys; may be {@code null} if no caching
+ * is desired.
+ * @return the {@link SshdSessionFactory}
+ */
+ public SshdSessionFactory build(KeyCache cache) {
+ // Use a copy to avoid that subsequent calls to setters affect an
+ // already created SshdSessionFactory.
+ return state.copy().build(cache);
+ }
+
+ private static class State {
+
+ ProxyDataFactory proxyDataFactory;
+
+ File homeDirectory;
+
+ File sshDirectory;
+
+ String preferredAuthentications;
+
+ Function<File, File> configFileFinder;
+
+ ConfigStoreFactory configFactory;
+
+ Function<CredentialsProvider, KeyPasswordProvider> passphraseProviderFactory;
+
+ Function<File, List<Path>> knownHostsFileFinder;
+
+ Function<File, List<Path>> defaultKeyFileFinder;
+
+ Function<File, Iterable<KeyPair>> defaultKeysProvider;
+
+ BiFunction<File, File, ServerKeyDatabase> serverKeyDatabaseCreator;
+
+ ConnectorFactory connectorFactory;
+
+ boolean connectorFactorySet;
+
+ State copy() {
+ State c = new State();
+ c.proxyDataFactory = proxyDataFactory;
+ c.homeDirectory = homeDirectory;
+ c.sshDirectory = sshDirectory;
+ c.preferredAuthentications = preferredAuthentications;
+ c.configFileFinder = configFileFinder;
+ c.configFactory = configFactory;
+ c.passphraseProviderFactory = passphraseProviderFactory;
+ c.knownHostsFileFinder = knownHostsFileFinder;
+ c.defaultKeyFileFinder = defaultKeyFileFinder;
+ c.defaultKeysProvider = defaultKeysProvider;
+ c.serverKeyDatabaseCreator = serverKeyDatabaseCreator;
+ c.connectorFactory = connectorFactory;
+ c.connectorFactorySet = connectorFactorySet;
+ return c;
+ }
+
+ SshdSessionFactory build(KeyCache cache) {
+ SshdSessionFactory factory = new SessionFactory(cache,
+ proxyDataFactory);
+ factory.setHomeDirectory(homeDirectory);
+ factory.setSshDirectory(sshDirectory);
+ return factory;
+ }
+
+ private class SessionFactory extends SshdSessionFactory {
+
+ public SessionFactory(KeyCache cache,
+ ProxyDataFactory proxyDataFactory) {
+ super(cache, proxyDataFactory);
+ }
+
+ @Override
+ protected File getSshConfig(File sshDir) {
+ if (configFileFinder != null) {
+ return configFileFinder.apply(sshDir);
+ }
+ return super.getSshConfig(sshDir);
+ }
+
+ @Override
+ protected List<Path> getDefaultKnownHostsFiles(File sshDir) {
+ if (knownHostsFileFinder != null) {
+ List<Path> result = knownHostsFileFinder.apply(sshDir);
+ return result == null ? Collections.emptyList() : result;
+ }
+ return super.getDefaultKnownHostsFiles(sshDir);
+ }
+
+ @Override
+ protected List<Path> getDefaultIdentities(File sshDir) {
+ if (defaultKeyFileFinder != null) {
+ List<Path> result = defaultKeyFileFinder.apply(sshDir);
+ return result == null ? Collections.emptyList() : result;
+ }
+ return super.getDefaultIdentities(sshDir);
+ }
+
+ @Override
+ protected String getDefaultPreferredAuthentications() {
+ if (!StringUtils.isEmptyOrNull(preferredAuthentications)) {
+ return preferredAuthentications;
+ }
+ return super.getDefaultPreferredAuthentications();
+ }
+
+ @Override
+ protected Iterable<KeyPair> getDefaultKeys(File sshDir) {
+ if (defaultKeysProvider != null) {
+ Iterable<KeyPair> result = defaultKeysProvider
+ .apply(sshDir);
+ return result == null ? Collections.emptyList() : result;
+ }
+ return super.getDefaultKeys(sshDir);
+ }
+
+ @Override
+ protected KeyPasswordProvider createKeyPasswordProvider(
+ CredentialsProvider provider) {
+ if (passphraseProviderFactory != null) {
+ KeyPasswordProvider result = passphraseProviderFactory
+ .apply(provider);
+ if (result != null) {
+ return result;
+ }
+ }
+ return super.createKeyPasswordProvider(provider);
+ }
+
+ @Override
+ protected ServerKeyDatabase createServerKeyDatabase(File homeDir,
+ File sshDir) {
+ if (serverKeyDatabaseCreator != null) {
+ ServerKeyDatabase result = serverKeyDatabaseCreator
+ .apply(homeDir, sshDir);
+ if (result != null) {
+ return result;
+ }
+ }
+ return super.createServerKeyDatabase(homeDir, sshDir);
+ }
+
+ @Override
+ protected SshConfigStore createSshConfigStore(File homeDir,
+ File configFile, String localUserName) {
+ if (configFactory != null) {
+ return configFactory.create(homeDir, configFile,
+ localUserName);
+ }
+ return super.createSshConfigStore(homeDir, configFile,
+ localUserName);
+ }
+
+ @Override
+ protected ConnectorFactory getConnectorFactory() {
+ if (connectorFactorySet) {
+ return connectorFactory;
+ }
+ // Use default via ServiceLoader
+ return super.getConnectorFactory();
+ }
+ }
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/AbstractConnector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/AbstractConnector.java
new file mode 100644
index 0000000000..71ddc3b003
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/AbstractConnector.java
@@ -0,0 +1,116 @@
+/*
+ * 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.transport.sshd.agent;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.Objects;
+
+import org.apache.sshd.agent.SshAgentConstants;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.util.buffer.BufferUtils;
+import org.eclipse.jgit.internal.transport.sshd.SshdText;
+
+/**
+ * Provides some utility methods for implementing {@link Connector}s.
+ *
+ * @since 6.0
+ */
+public abstract class AbstractConnector implements Connector {
+
+ // A somewhat sane lower bound for the maximum reply length
+ private static final int MIN_REPLY_LENGTH = 8 * 1024;
+
+ /**
+ * Default maximum reply length. 256kB is the OpenSSH limit.
+ */
+ protected static final int DEFAULT_MAX_REPLY_LENGTH = 256 * 1024;
+
+ private final int maxReplyLength;
+
+ /**
+ * Creates a new instance using the {@link #DEFAULT_MAX_REPLY_LENGTH}.
+ */
+ protected AbstractConnector() {
+ this(DEFAULT_MAX_REPLY_LENGTH);
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param maxReplyLength
+ * maximum number of payload bytes we're ready to accept
+ */
+ protected AbstractConnector(int maxReplyLength) {
+ if (maxReplyLength < MIN_REPLY_LENGTH) {
+ throw new IllegalArgumentException(
+ "Maximum payload length too small"); //$NON-NLS-1$
+ }
+ this.maxReplyLength = maxReplyLength;
+ }
+
+ /**
+ * Retrieves the maximum message length this {@link AbstractConnector} is
+ * configured for.
+ *
+ * @return the maximum message length
+ */
+ protected int getMaximumMessageLength() {
+ return this.maxReplyLength;
+ }
+
+ /**
+ * Prepares a message for sending by inserting the command and message
+ * length.
+ *
+ * @param command
+ * SSH agent command the request is for
+ * @param message
+ * about to be sent, including the 5 spare bytes at the front
+ * @throws IllegalArgumentException
+ * if {@code message} has less than 5 bytes
+ */
+ protected void prepareMessage(byte command, byte[] message)
+ throws IllegalArgumentException {
+ Objects.requireNonNull(message);
+ if (message.length < 5) {
+ // No translation; internal error
+ throw new IllegalArgumentException("Message buffer for " //$NON-NLS-1$
+ + SshAgentConstants.getCommandMessageName(command)
+ + " must have at least 5 bytes; have only " //$NON-NLS-1$
+ + message.length);
+ }
+ BufferUtils.putUInt(message.length - 4, message);
+ message[4] = command;
+ }
+
+ /**
+ * Checks the received length of a reply.
+ *
+ * @param command
+ * SSH agent command the reply is for
+ * @param length
+ * length as received: number of payload bytes
+ * @return the length as an {@code int}
+ * @throws IOException
+ * if the length is invalid
+ */
+ protected int toLength(byte command, byte[] length)
+ throws IOException {
+ long l = BufferUtils.getUInt(length);
+ if (l <= 0 || l > maxReplyLength - 4) {
+ throw new SshException(MessageFormat.format(
+ SshdText.get().sshAgentReplyLengthError,
+ Long.toString(l),
+ SshAgentConstants.getCommandMessageName(command)));
+ }
+ return (int) l;
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/Connector.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/Connector.java
new file mode 100644
index 0000000000..b391cf4884
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/Connector.java
@@ -0,0 +1,62 @@
+/*
+ * 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.transport.sshd.agent;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * Simple interface for connecting to something and making RPC-style
+ * request-reply calls.
+ *
+ * @see ConnectorFactory
+ * @since 6.0
+ */
+public interface Connector extends Closeable {
+
+ /**
+ * Connects to an SSH agent if there is one running. If called when already
+ * connected just returns {@code true}.
+ *
+ * @return {@code true} if an SSH agent is available and connected,
+ * {@code false} if no SSH agent is available
+ * @throws IOException
+ * if connecting to the SSH agent failed
+ */
+ boolean connect() throws IOException;
+
+ /**
+ * Performs a remote call to the SSH agent and returns the result.
+ *
+ * @param command
+ * to send
+ * @param message
+ * to send; must have at least 5 bytes, and must have 5 unused
+ * bytes at the front.
+ * @return the result received
+ * @throws IOException
+ * if an error occurs
+ */
+ byte[] rpc(byte command, byte[] message) throws IOException;
+
+ /**
+ * Performs a remote call sending only a command without any parameters to
+ * the SSH agent and returns the result.
+ *
+ * @param command
+ * to send
+ * @return the result received
+ * @throws IOException
+ * if an error occurs
+ */
+ default byte[] rpc(byte command) throws IOException {
+ return rpc(command, new byte[5]);
+ }
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/ConnectorFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/ConnectorFactory.java
new file mode 100644
index 0000000000..da98ea7fe0
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/ConnectorFactory.java
@@ -0,0 +1,173 @@
+/*
+ * 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.transport.sshd.agent;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collection;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.internal.transport.sshd.agent.ConnectorFactoryProvider;
+
+/**
+ * A factory for creating {@link Connector}s. This is a service provider
+ * interface; implementations are discovered via the
+ * {@link java.util.ServiceLoader}, or can be set explicitly on a
+ * {@link org.eclipse.jgit.transport.sshd.SshdSessionFactory}.
+ *
+ * @since 6.0
+ */
+public interface ConnectorFactory {
+
+ /**
+ * Retrieves the currently set default {@link ConnectorFactory}. This is the
+ * factory that is used unless overridden by the
+ * {@link org.eclipse.jgit.transport.sshd.SshdSessionFactory}.
+ *
+ * @return the current default factory; may be {@code null} if none is set
+ * and the {@link java.util.ServiceLoader} cannot find any suitable
+ * implementation
+ */
+ static ConnectorFactory getDefault() {
+ return ConnectorFactoryProvider.getDefaultFactory();
+ }
+
+ /**
+ * Sets a default {@link ConnectorFactory}. This is the factory that is used
+ * unless overridden by the
+ * {@link org.eclipse.jgit.transport.sshd.SshdSessionFactory}.
+ * <p>
+ * If no default factory is set programmatically, an implementation is
+ * discovered via the {@link java.util.ServiceLoader}.
+ * </p>
+ *
+ * @param factory
+ * {@link ConnectorFactory} to set, or {@code null} to revert to
+ * the default behavior of using the
+ * {@link java.util.ServiceLoader}.
+ */
+ static void setDefault(ConnectorFactory factory) {
+ ConnectorFactoryProvider.setDefaultFactory(factory);
+ }
+
+ /**
+ * Creates a new {@link Connector}.
+ *
+ * @param identityAgent
+ * identifies the wanted agent connection; if {@code null}, the
+ * factory is free to provide a {@link Connector} to a default
+ * agent. The value will typically come from the
+ * {@code IdentityAgent} setting in {@code ~/.ssh/config}.
+ * @param homeDir
+ * the current local user's home directory as configured in the
+ * {@link org.eclipse.jgit.transport.sshd.SshdSessionFactory}
+ * @return a new {@link Connector}
+ * @throws IOException
+ * if no connector can be created
+ */
+ @NonNull
+ Connector create(String identityAgent, File homeDir)
+ throws IOException;
+
+ /**
+ * Tells whether this {@link ConnectorFactory} is applicable on the
+ * currently running platform.
+ *
+ * @return {@code true} if the factory can be used, {@code false} otherwise
+ */
+ boolean isSupported();
+
+ /**
+ * Retrieves a name for this factory.
+ *
+ * @return the name
+ */
+ String getName();
+
+ /**
+ * {@link ConnectorDescriptor}s describe available {@link Connector}s a
+ * {@link ConnectorFactory} may provide.
+ * <p>
+ * A {@link ConnectorFactory} may support connecting to different SSH
+ * agents. Agents are identified by name; a user can choose a specific agent
+ * for instance via the {@code IdentityAgent} setting in
+ * {@code ~/.ssh/config}.
+ * </p>
+ * <p>
+ * OpenSSH knows two built-in names: "none" for not using any agent, and
+ * "SSH_AUTH_SOCK" for using an agent that communicates over a Unix domain
+ * socket given by the value of environment variable {@code SSH_AUTH_SOCK}.
+ * Other agents can be specified in OpenSSH by specifying the socket file
+ * directly. (The "standard" OpenBSD OpenSSH knows only this communication
+ * mechanism.) "SSH_AUTH_SOCK" is also the default in OpenBSD OpenSSH if
+ * nothing is configured.
+ * </p>
+ * <p>
+ * A particular {@link ConnectorFactory} may support more communication
+ * mechanisms or different agents. For instance, a factory on Windows might
+ * support Pageant, Win32-OpenSSH, or even git bash ssh-agent, and might
+ * accept internal names like "pageant", "openssh", "SSH_AUTH_SOCK" in
+ * {@link ConnectorFactory#create(String, File)} to choose among them.
+ * </p>
+ * The {@link ConnectorDescriptor} interface and the
+ * {@link ConnectorFactory#getSupportedConnectors()} and
+ * {@link ConnectorFactory#getDefaultConnector()} methods provide a way for
+ * code using a {@link ConnectorFactory} to learn what the factory supports
+ * and thus implement some way by which a user can influence the default
+ * behavior if {@code IdentityAgent} is not set or
+ * {@link ConnectorFactory#create(String, File)} is called with
+ * {@code identityAgent == null}.
+ */
+ interface ConnectorDescriptor {
+
+ /**
+ * Retrieves the internal name of a supported {@link Connector}. The
+ * internal name is the one a user can specify for instance in the
+ * {@code IdentityAgent} setting in {@code ~/.ssh/config} to select the
+ * connector.
+ *
+ * @return the internal name; not empty
+ */
+ @NonNull
+ String getIdentityAgent();
+
+ /**
+ * Retrieves a display name for a {@link Connector}, suitable for
+ * showing in a UI.
+ *
+ * @return the display name; properly localized and not empty
+ */
+ @NonNull
+ String getDisplayName();
+ }
+
+ /**
+ * Tells which kinds of SSH agents this {@link ConnectorFactory} supports.
+ * <p>
+ * An implementation of this method should document the possible values it
+ * returns.
+ * </p>
+ *
+ * @return an immutable collection of {@link ConnectorDescriptor}s,
+ * including {@link #getDefaultConnector()} and not including a
+ * descriptor for internal name "none"
+ */
+ @NonNull
+ Collection<ConnectorDescriptor> getSupportedConnectors();
+
+ /**
+ * Tells what kind of {@link Connector} this {@link ConnectorFactory}
+ * creates if {@link ConnectorFactory#create(String, File)} is called with
+ * {@code identityAgent == null}.
+ *
+ * @return a {@link ConnectorDescriptor} for the default connector
+ */
+ ConnectorDescriptor getDefaultConnector();
+}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/package-info.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/package-info.java
new file mode 100644
index 0000000000..71ca43f3d5
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/agent/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * Service provider interfaces for connecting to an SSH agent. Implementations
+ * are discovered via the {@link java.util.ServiceLoader}, or can be set
+ * explicitly on a {@link org.eclipse.jgit.transport.sshd.SshdSessionFactory}.
+ */
+package org.eclipse.jgit.transport.sshd.agent;
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/package-info.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/package-info.java
new file mode 100644
index 0000000000..926234a3bd
--- /dev/null
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * Provides a JGit {@link org.eclipse.jgit.transport.SshSessionFactory}
+ * implemented via <a href="https://mina.apache.org/sshd-project/">Apache MINA
+ * sshd</a>.
+ */
+package org.eclipse.jgit.transport.sshd;