diff options
Diffstat (limited to 'org.eclipse.jgit.ssh.apache')
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 < 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 <key>"; 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 < 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 > 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; >= 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 <= 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 >= 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; |