├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ ├── Feature_request.md │ ├── Question.md │ └── config.yml └── workflows │ ├── ci.yml │ └── stale.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── CONTRIBUTING.md ├── Directory.Build.props ├── Directory.Build.targets ├── LICENSE.txt ├── LettuceEncrypt.sln ├── README.md ├── SECURITY.md ├── build.ps1 ├── codecov.yml ├── samples ├── KeyVault │ ├── KeyVault.csproj │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Startup.cs │ ├── appsettings.Development.json │ ├── appsettings.Production.json │ ├── appsettings.Staging.json │ └── appsettings.json └── Web │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Startup.cs │ ├── Web.csproj │ ├── appsettings.Development.json │ ├── appsettings.Production.json │ ├── appsettings.Staging.json │ └── appsettings.json ├── src ├── Directory.Build.targets ├── Kestrel.Certificates │ ├── IServerCertificateSelector.cs │ ├── KestrelHttpsOptionsExtensions.cs │ ├── McMaster.AspNetCore.Kestrel.Certificates.csproj │ ├── PublicAPI.Shipped.txt │ ├── PublicAPI.Unshipped.txt │ └── releasenotes.props ├── LettuceEncrypt.Azure │ ├── AzureKeyVaultLettuceEncryptOptions.cs │ ├── AzureLettuceEncryptExtensions.cs │ ├── Internal │ │ ├── AzureKeyVaultAccountStore.cs │ │ ├── AzureKeyVaultCertificateRepository.cs │ │ ├── CertificateClientFactory.cs │ │ └── SecretClientFactory.cs │ ├── LettuceEncrypt.Azure.csproj │ ├── Properties │ │ └── AssemblyInfo.cs │ ├── PublicAPI.Shipped.txt │ ├── PublicAPI.Unshipped.txt │ └── releasenotes.props ├── LettuceEncrypt │ ├── Accounts │ │ ├── AccountModel.cs │ │ └── IAccountStore.cs │ ├── Acme │ │ ├── ChallengeType.cs │ │ ├── DnsTxtRecordContext.cs │ │ ├── EabCredentials.cs │ │ ├── ICertificateAuthorityConfiguration.cs │ │ └── IDnsChallengeProvider.cs │ ├── FileSystemPersistenceExtensions.cs │ ├── ICertificateRepository.cs │ ├── ICertificateSource.cs │ ├── ILettuceEncryptServiceBuilder.cs │ ├── Internal │ │ ├── AcmeCertificateFactory.cs │ │ ├── AcmeCertificateLoader.cs │ │ ├── AcmeClient.cs │ │ ├── AcmeClientFactory.cs │ │ ├── AcmeStates │ │ │ ├── AcmeState.cs │ │ │ ├── AcmeStateMachineContext.cs │ │ │ ├── BeginCertificateCreationState.cs │ │ │ ├── CheckForRenewalState.cs │ │ │ └── ServerStartupState.cs │ │ ├── CertificateSelector.cs │ │ ├── DefaultCertificateAuthorityConfiguration.cs │ │ ├── DeveloperCertLoader.cs │ │ ├── Dns01DomainValidator.cs │ │ ├── DomainOwnershipValidator.cs │ │ ├── FileSystemAccountStore.cs │ │ ├── FileSystemCertificateRepository.cs │ │ ├── Http01DomainValidator.cs │ │ ├── HttpChallengeResponseMiddleware.cs │ │ ├── HttpChallengeStartupFilter.cs │ │ ├── IHttpChallengeResponseStore.cs │ │ ├── IO │ │ │ ├── IClock.cs │ │ │ ├── IConsole.cs │ │ │ ├── PhysicalConsole.cs │ │ │ └── SystemClock.cs │ │ ├── InMemoryHttpChallengeStore.cs │ │ ├── KestrelOptionsSetup.cs │ │ ├── LettuceEncryptApplicationBuilderExtensions.cs │ │ ├── LettuceEncryptServiceBuilder.cs │ │ ├── LoggerExtensions.cs │ │ ├── NoOpDnsChallengeProvider.cs │ │ ├── OptionsValidation.cs │ │ ├── PfxBuilder │ │ │ ├── IPfxBuilder.cs │ │ │ ├── IPfxBuilderFactory.cs │ │ │ ├── PfxBuilderFactory.cs │ │ │ └── PfxBuilderWrapper.cs │ │ ├── StartupCertificateLoader.cs │ │ ├── TermsOfServiceChecker.cs │ │ ├── TlsAlpn01DomainValidator.cs │ │ ├── TlsAlpnChallengeResponder.cs │ │ ├── X509CertStore.cs │ │ └── X509CertificateHelpers.cs │ ├── KeyAlgorithm.cs │ ├── LettuceEncrypt.csproj │ ├── LettuceEncryptKestrelHttpsOptionsExtensions.cs │ ├── LettuceEncryptOptions.cs │ ├── LettuceEncryptServiceCollectionExtensions.cs │ ├── Properties │ │ └── AssemblyInfo.cs │ ├── PublicAPI.Shipped.txt │ ├── PublicAPI.Unshipped.txt │ └── releasenotes.props ├── StrongName.snk ├── common.psm1 └── icon.png └── test ├── Integration ├── README.md ├── ngrok.yml └── run_ngrok ├── LettuceEncrypt.Azure.UnitTests ├── AzureKeyVaultAccountStoreTests.cs ├── AzureKeyVaultTests.cs ├── ConfigurationBindingTests.cs └── LettuceEncrypt.Azure.UnitTests.csproj └── LettuceEncrypt.UnitTests ├── AcmeCertificateFactoryTest.cs ├── CertificateSelectorTests.cs ├── ChallengeTypeTests.cs ├── ConfigurationBindingTests.cs ├── DefaultCertificateAuthorityConfigurationTests.cs ├── DeveloperCertLoaderTests.cs ├── FileSystemAccountStoreTests.cs ├── FileSystemCertificateRepoTests.cs ├── HttpChallengeResponseMiddlewareTests.cs ├── KestrelHttpsOptionsExtensionsTests.cs ├── KestrelOptionsSetupTests.cs ├── LettuceEncrypt.UnitTests.csproj ├── SkipOnWindowsCIBuildAttribute.cs ├── StartupCertificateLoaderTests.cs ├── TermsOfServiceCheckerTests.cs ├── TestUtils.cs └── X509CertStoreTests.cs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | 8 | [*.{xml,csproj,props,targets,config}] 9 | indent_size = 2 10 | 11 | [*.json] 12 | indent_size = 2 13 | 14 | [*.cs] 15 | indent_size = 4 16 | 17 | # Style I care about 18 | csharp_style_expression_bodied_constructors = false 19 | csharp_prefer_braces = true 20 | dotnet_sort_system_directives_first = true 21 | csharp_using_directive_placement = outside_namespace 22 | 23 | # Stuff that is usually best 24 | csharp_style_inlined_variable_declaration = true 25 | csharp_style_var_elsewhere = true 26 | csharp_space_after_cast = false 27 | csharp_style_pattern_matching_over_as_with_null_check = true 28 | csharp_style_pattern_matching_over_is_with_cast_check = true 29 | csharp_style_var_for_built_in_types = true 30 | csharp_style_var_when_type_is_apparent = true 31 | csharp_new_line_before_catch = true 32 | csharp_new_line_before_else = true 33 | csharp_new_line_before_finally = true 34 | csharp_indent_case_contents = true 35 | csharp_new_line_before_open_brace = all 36 | csharp_indent_switch_labels = true 37 | csharp_indent_labels = one_less_than_current 38 | csharp_prefer_simple_default_expression = true 39 | csharp_preserve_single_line_blocks = true 40 | csharp_preserve_single_line_statements = true 41 | 42 | # Good defaults, but not always 43 | dotnet_style_object_initializer = true 44 | csharp_style_expression_bodied_indexers = true 45 | csharp_style_expression_bodied_accessors = true 46 | csharp_style_throw_expression = true 47 | csharp_style_namespace_declarations = file_scoped:suggestion 48 | 49 | # Default severity for analyzer diagnostics with category 'Style' (escalated to build warnings) 50 | # dotnet_analyzer_diagnostic.category-Style.severity = suggestion 51 | 52 | # Required naming style 53 | dotnet_diagnostic.IDE0006.severity = error 54 | 55 | # suppress warning aboud unused methods 56 | dotnet_diagnostic.IDE0051.severity = none 57 | 58 | # Missing required header 59 | dotnet_diagnostic.IDE0040.severity = error 60 | 61 | # Missing accessibility modifier 62 | dotnet_diagnostic.IDE0073.severity = warning 63 | 64 | # Remove unnecessary parenthesis 65 | dotnet_diagnostic.IDE0047.severity = warning 66 | 67 | # Parenthesis added for clarity 68 | dotnet_diagnostic.IDE0048.severity = warning 69 | 70 | # Suppress explicit type instead of var 71 | dotnet_diagnostic.IDE0008.severity = none 72 | 73 | # Suppress unused expression 74 | dotnet_diagnostic.IDE0058.severity = none 75 | 76 | 77 | 78 | # Naming styles 79 | 80 | ## Constants are PascalCase 81 | dotnet_naming_style.pascal_case.capitalization = pascal_case 82 | 83 | dotnet_naming_symbols.constants.applicable_kinds = field, property 84 | dotnet_naming_symbols.constants.applicable_accessibilities = * 85 | dotnet_naming_symbols.constants.required_modifiers = const 86 | 87 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants 88 | dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case 89 | dotnet_naming_rule.constants_should_be_pascal_case.severity = error 90 | 91 | ## Private static fields start with s_ 92 | dotnet_naming_style.s_underscore_camel_case.required_prefix = s_ 93 | dotnet_naming_style.s_underscore_camel_case.capitalization = camel_case 94 | 95 | dotnet_naming_symbols.private_static_fields.applicable_kinds = field 96 | dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private 97 | dotnet_naming_symbols.private_static_fields.required_modifiers = static 98 | 99 | dotnet_naming_rule.private_static_fields_should_be_underscore.symbols = private_static_fields 100 | dotnet_naming_rule.private_static_fields_should_be_underscore.style = s_underscore_camel_case 101 | dotnet_naming_rule.private_static_fields_should_be_underscore.severity = suggestion 102 | 103 | ## Private fields are _camelCase 104 | dotnet_naming_style.underscore_camel_case.required_prefix = _ 105 | dotnet_naming_style.underscore_camel_case.capitalization = camel_case 106 | 107 | dotnet_naming_symbols.private_fields.applicable_kinds = field 108 | dotnet_naming_symbols.private_fields.applicable_accessibilities = private 109 | 110 | dotnet_naming_rule.private_fields_should_be_underscore.symbols = private_fields 111 | dotnet_naming_rule.private_fields_should_be_underscore.style = underscore_camel_case 112 | dotnet_naming_rule.private_fields_should_be_underscore.severity = error 113 | 114 | # File header 115 | file_header_template = Copyright (c) Nate McMaster.\nLicensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 116 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @natemcmaster 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Using this version of the library '...' 16 | 2. Run this code '....' 17 | 3. With these arguments '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | Example. I'm am trying to do [...] but [...] 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A description of any alternative solutions or features you've considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question 4 | title: "[Question] " 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | If you're not exactly sure what to say, here are some suggestions: https://stackoverflow.com/help/how-to-ask 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - dependabot/* 7 | pull_request: 8 | workflow_dispatch: 9 | inputs: 10 | is_stable_build: 11 | description: Use a version number indicating this is a stable release 12 | required: true 13 | default: "false" 14 | release: 15 | description: Create a release 16 | required: true 17 | default: "false" 18 | 19 | env: 20 | IS_STABLE_BUILD: ${{ github.event.inputs.is_stable_build }} 21 | BUILD_NUMBER: ${{ github.run_number }} 22 | 23 | jobs: 24 | build: 25 | if: ${{ !contains(github.event.head_commit.message, 'ci skip') || github.event_name == 'workflow_dispatch' }} 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | os: [windows-latest, ubuntu-latest, macos-latest] 30 | 31 | runs-on: ${{ matrix.os }} 32 | 33 | outputs: 34 | package_version: ${{ steps.build_script.outputs.package_version }} 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Setup .NET 39 | uses: actions/setup-dotnet@v4 40 | with: 41 | dotnet-version: | 42 | 6.0.x 43 | 7.0.x 44 | - name: Run build script 45 | id: build_script 46 | run: ./build.ps1 -ci 47 | - uses: actions/upload-artifact@v4 48 | if: ${{ matrix.os == 'windows-latest' }} 49 | with: 50 | name: packages 51 | path: artifacts/ 52 | if-no-files-found: error 53 | - uses: codecov/codecov-action@v4 54 | with: 55 | name: unittests-${{ matrix.os }} 56 | fail_ci_if_error: true 57 | token: ${{ secrets.CODECOV_TOKEN }} 58 | release: 59 | if: ${{ github.event.inputs.release }} 60 | needs: build 61 | runs-on: windows-latest 62 | env: 63 | PACKAGE_VERSION: ${{ needs.build.outputs.package_version }} 64 | steps: 65 | - run: echo "Releasing ${{ env.PACKAGE_VERSION }}" 66 | - name: Setup NuGet 67 | uses: NuGet/setup-nuget@v2 68 | with: 69 | nuget-version: latest 70 | - uses: actions/download-artifact@v4 71 | with: 72 | name: packages 73 | path: packages 74 | - name: Configure GitHub NuGet registry 75 | run: nuget sources add -name github -source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json -username ${{ github.repository_owner }} -password ${{ secrets.GITHUB_TOKEN }} 76 | - name: Push to GitHub package registry 77 | run: nuget push packages\*.nupkg -ApiKey ${{ secrets.GITHUB_TOKEN }} -Source github -SkipDuplicate 78 | - name: Push to NuGet.org 79 | run: nuget push packages\*.nupkg -ApiKey ${{ secrets.NUGET_API_KEY }} -Source https://api.nuget.org/v3/index.json -SkipDuplicate 80 | - name: Create GitHub release 81 | uses: softprops/action-gh-release@v2 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | with: 85 | name: ${{ env.PACKAGE_VERSION }} 86 | tag_name: v${{ env.PACKAGE_VERSION }} 87 | generate_release_notes: true 88 | append_body: true 89 | body: | 90 | ### How to get this update 91 | 92 | Packages have been posted to these feeds: 93 | 94 | #### NuGet.org 95 | https://nuget.org/packages/LettuceEncrypt/${{ env.PACKAGE_VERSION }} 96 | https://nuget.org/packages/LettuceEncrypt.Azure/${{ env.PACKAGE_VERSION }} 97 | 98 | #### GitHub Package Registry 99 | https://github.com/natemcmaster?tab=packages&repo_name=LettuceEncrypt 100 | 101 | draft: true 102 | prerelease: ${{ env.IS_STABLE_BUILD == 'false' }} # Example: v3.1.0-beta 103 | files: packages/* 104 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | debug-only: 6 | description: Run in debug mode 7 | required: false 8 | default: 'false' 9 | schedule: 10 | - cron: '30 1 * * *' 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@v5 17 | with: 18 | debug-only: ${{ github.event.inputs.debug-only == 'true' }} 19 | days-before-stale: 365 20 | days-before-close: 14 21 | stale-issue-label: stale 22 | close-issue-label: closed-stale 23 | close-issue-reason: not_planned 24 | exempt-issue-labels: announcement,planning 25 | exempt-all-milestones: true 26 | exempt-all-assignees: true 27 | stale-issue-message: > 28 | This issue has been automatically marked as stale because it has no recent activity. 29 | It will be closed if no further activity occurs. Please comment if you believe this 30 | should remain open, otherwise it will be closed in 14 days. 31 | Thank you for your contributions to this project. 32 | close-issue-message: > 33 | Closing due to inactivity. 34 | 35 | If you are looking at this issue in the future and think it should be reopened, 36 | please make a commented here and mention natemcmaster so he sees the notification. 37 | stale-pr-message: > 38 | This pull request appears to be stale. Please comment if you believe this should remain 39 | open and reviewed. If there are no updates, it will be closed in 14 days. 40 | close-pr-message: > 41 | Thank you for your contributions to this project. This pull request has been closed due to inactivity. 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | obj/ 3 | bin/ 4 | .vs/ 5 | .build/ 6 | artifacts/ 7 | *.user 8 | TestResults/ 9 | .idea/ 10 | *.iml 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": ".NET Core Launch (web)", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "${workspaceFolder}/.build/Web/bin/Debug/net7.0/Web.dll", 10 | "args": [], 11 | "cwd": "${workspaceFolder}/samples/Web", 12 | "stopAtEntry": false, 13 | "internalConsoleOptions": "openOnSessionStart", 14 | "launchBrowser": { 15 | "enabled": false, 16 | "args": "${auto-detect-url}", 17 | "windows": { 18 | "command": "cmd.exe", 19 | "args": "/C start ${auto-detect-url}" 20 | }, 21 | "osx": { 22 | "command": "open" 23 | }, 24 | "linux": { 25 | "command": "xdg-open" 26 | } 27 | }, 28 | "env": { 29 | "ASPNETCORE_ENVIRONMENT": "Staging", 30 | "ASPNETCORE_URLS": "http://localhost:5001;https://+:5002" 31 | }, 32 | "sourceFileMap": { 33 | "/Views": "${workspaceFolder}/Views" 34 | } 35 | }, 36 | { 37 | "name": ".NET Core Attach", 38 | "type": "coreclr", 39 | "request": "attach", 40 | "processId": "${command:pickProcess}" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "--no-restore" 11 | ], 12 | "options": { 13 | "cwd": "${workspaceFolder}" 14 | }, 15 | "problemMatcher": "$msCompile", 16 | "presentation": { 17 | "echo": true, 18 | "reveal": "always", 19 | "focus": false, 20 | "panel": "shared", 21 | "showReuseMessage": true, 22 | "clear": true 23 | }, 24 | "group": { 25 | "kind": "build", 26 | "isDefault": true 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing Guide 2 | ================== 3 | 4 | Contributions are welcome! If you would like to help out, here are some suggestions for how to get involved. 5 | 6 | ## Get involved 7 | [Watch][watchers] this repository to get notifications about all conversations. GitHub issues and pull requests are the authoritative 8 | source of truth for design reviews, release schedules, and bug fixes. 9 | 10 | ## You don't have to contribute code 11 | 12 | There are more ways to help that don't involve writing code. 13 | 14 | * Respond to new issues. Users often open an issue to ask a question. You are welcome to offer your answer on the thread. 15 | * :+1: Up vote features that you think are important. 16 | * Look through issues labeled [closed-stale][closed-stale] to see if there are feature requests worth reviving. 17 | * Review pull requests. 18 | 19 | ## Contributing code 20 | 21 | * Open issues labeled ["help wanted"][help-wanted] are issues that I think are worth doing, but no one has volunteered to do the work yet. 22 | Make a comment on issues you want assigned to yourself. 23 | * Pull requests are more likely to be accepted if I have first agreed to accept a feature or bug fix. Open an issue first if you aren't sure. 24 | 25 | ## Questions? 26 | 27 | Open a GitHub issue if you'd like to help and don't know where to begin. 28 | 29 | [watchers]: https://github.com/natemcmaster/LettuceEncrypt/watchers 30 | [closed-stale]: https://github.com/natemcmaster/LettuceEncrypt/labels/closed-stale 31 | [help-wanted]: https://github.com/natemcmaster/LettuceEncrypt/labels/help%20wanted 32 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | lets-encrypt;aspnetcore;https;certificates 4 | Nate McMaster 5 | Copyright © Nate McMaster 6 | en-US 7 | false 8 | Apache-2.0 9 | https://github.com/natemcmaster/LettuceEncrypt 10 | https://github.com/natemcmaster/LettuceEncrypt 11 | git 12 | snupkg 13 | portable 14 | false 15 | false 16 | true 17 | true 18 | 10.0 19 | enable 20 | true 21 | $(MSBuildThisFileDirectory)src\StrongName.snk 22 | true 23 | $(NoWarn);NU5105 24 | $(WarningsNotAsErrors);CS1591 25 | false 26 | 27 | true 28 | true 29 | true 30 | $(MSBuildThisFileDirectory).build\$(MSBuildProjectName)\bin\ 31 | $(MSBuildThisFileDirectory).build\$(MSBuildProjectName)\obj\ 32 | 33 | $(DefaultItemExcludes);TestResults\** 34 | 35 | 36 | 37 | 1.3.3 38 | beta 39 | true 40 | $([MSBuild]::ValueOrDefault($(BUILD_NUMBER), 0)) 41 | $(VersionSuffix).$(BuildNumber) 42 | $(BUILD_SOURCEVERSION) 43 | $(VersionPrefix) 44 | $(PackageVersion)-$(VersionSuffix) 45 | 46 | 47 | See release notes at https://github.com/natemcmaster/LettuceEncrypt/releases/tag/v$(PackageVersion). 48 | 49 | 50 | 51 | 52 | icon.png 53 | https://raw.githubusercontent.com/wiki/natemcmaster/LettuceEncrypt/logo.png 54 | 55 | 56 | 57 | 58 | true 59 | /$(PackageIcon) 60 | false 61 | 62 | 63 | 64 | 66 | 67 | -------------------------------------------------------------------------------- /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | $(PackageDescription) 9 | 10 | This package was build from source code at $(RepositoryUrl)/tree/$(SourceRevisionId) 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | It is highly recommended that you use the newest versions of this library as they are released. 6 | Only the latest version of this library will be supported with security updates. 7 | 8 | ## Reporting a Vulnerability 9 | 10 | If you have found an issue that you believe is a security vulnerability, DO NOT OPEN an issue on GitHub. Instead, 11 | email your concerns to open-source@natemcmaster.com. Security concerns will be reviewed as soon as possible and 12 | you can expect a reply within two weeks. 13 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | [CmdletBinding(PositionalBinding = $false)] 3 | param( 4 | [ValidateSet('Debug', 'Release')] 5 | $Configuration = $null, 6 | [switch] 7 | $ci, 8 | [Parameter(ValueFromRemainingArguments = $true)] 9 | [string[]]$MSBuildArgs 10 | ) 11 | 12 | Set-StrictMode -Version 1 13 | $ErrorActionPreference = 'Stop' 14 | 15 | Import-Module -Force -Scope Local "$PSScriptRoot/src/common.psm1" 16 | 17 | # 18 | # Main 19 | # 20 | 21 | if ($env:CI -eq 'true') { 22 | $ci = $true 23 | } 24 | 25 | if (!$Configuration) { 26 | $Configuration = if ($ci) { 'Release' } else { 'Debug' } 27 | } 28 | 29 | if ($ci) { 30 | $MSBuildArgs += '-p:CI=true' 31 | 32 | & dotnet --info 33 | } 34 | 35 | if (-not (Test-Path variable:\IsCoreCLR)) { 36 | $IsWindows = $true 37 | } 38 | 39 | $artifacts = "$PSScriptRoot/artifacts/" 40 | 41 | Remove-Item -Recurse $artifacts -ErrorAction Ignore 42 | 43 | [string[]] $formatArgs=@() 44 | if ($ci) { 45 | $formatArgs += '--verify-no-changes' 46 | } 47 | 48 | exec dotnet format -v detailed @formatArgs 49 | exec dotnet build --configuration $Configuration '-warnaserror:CS1591' @MSBuildArgs 50 | exec dotnet pack --no-restore --no-build --configuration $Configuration -o $artifacts @MSBuildArgs 51 | 52 | [string[]] $testArgs=@() 53 | if ($env:TF_BUILD) { 54 | $testArgs += '--logger', 'trx' 55 | } 56 | 57 | exec dotnet test --no-restore --no-build --configuration $Configuration '-clp:Summary' ` 58 | --collect:"XPlat Code Coverage" ` 59 | @testArgs ` 60 | @MSBuildArgs 61 | 62 | write-host -f green 'BUILD SUCCEEDED' 63 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - samples 3 | - test 4 | -------------------------------------------------------------------------------- /samples/KeyVault/KeyVault.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /samples/KeyVault/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace Web; 5 | 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder.UseStartup(); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /samples/KeyVault/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Development": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "http://localhost:5001;https://localhost:5002" 10 | }, 11 | "Staging": { 12 | "commandName": "Project", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Staging" 16 | }, 17 | "applicationUrl": "http://localhost:5001;https://localhost:5002" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /samples/KeyVault/Startup.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace Web; 5 | 6 | public class Startup 7 | { 8 | public void ConfigureServices(IServiceCollection services) 9 | { 10 | services 11 | .AddLettuceEncrypt() 12 | .PersistCertificatesToAzureKeyVault(); 13 | } 14 | 15 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 16 | { 17 | if (env.IsDevelopment()) 18 | { 19 | app.UseDeveloperExceptionPage(); 20 | } 21 | 22 | app.UseRouting(); 23 | 24 | app.UseEndpoints(endpoints => 25 | { 26 | endpoints.MapGet("/", async context => 27 | { 28 | await context.Response.WriteAsync("Hello World!"); 29 | }); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /samples/KeyVault/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "LettuceEncrypt": { 3 | // Uncomment to test in the dev environment 4 | // See CONTRIBUTING.md for instructions 5 | 6 | // "DomainNames": [ 7 | // "XYZ.ngrok.io" 8 | // ], 9 | // 10 | // "AzureKeyVault": { 11 | // "AzureKeyVaultEndpoint": "https://XYZ.vault.azure.net" 12 | // } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /samples/KeyVault/appsettings.Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "LettuceEncrypt": { 3 | "DomainNames": [ 4 | "example.com", 5 | "www.example.com", 6 | "www2.example.com" 7 | ], 8 | "AzureKeyVault": { 9 | "AzureKeyVaultEndpoint": "https://my-production-secrets.vault.azure.net" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /samples/KeyVault/appsettings.Staging.json: -------------------------------------------------------------------------------- 1 | { 2 | "LettuceEncrypt": { 3 | "DomainNames": [ 4 | "staging.example.com" 5 | ], 6 | "AzureKeyVault": { 7 | "AzureKeyVaultEndpoint": "https://my-staging-secrets.vault.azure.net" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/KeyVault/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "LettuceEncrypt": "Trace", 6 | "Microsoft": "Warning", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | }, 10 | "AllowedHosts": "*", 11 | "LettuceEncrypt": { 12 | "AcceptTermsOfService": true, 13 | "EmailAddress": "admin@example.com" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /samples/Web/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Net; 5 | using Microsoft.AspNetCore.Server.Kestrel.Https; 6 | 7 | namespace Web; 8 | 9 | public class Program 10 | { 11 | public static void Main(string[] args) 12 | { 13 | CreateHostBuilder(args).Build().Run(); 14 | } 15 | 16 | public static IHostBuilder CreateHostBuilder(string[] args) => 17 | Host.CreateDefaultBuilder(args) 18 | .ConfigureWebHostDefaults(webBuilder => 19 | { 20 | webBuilder.UseStartup(); 21 | 22 | // This example shows how to configure Kestrel's client certificate requirements along with 23 | // enabling Lettuce Encrypt's certificate automation. 24 | if (Environment.GetEnvironmentVariable("REQUIRE_CLIENT_CERT") == "true") 25 | { 26 | webBuilder.UseKestrel(k => 27 | { 28 | var appServices = k.ApplicationServices; 29 | k.ConfigureHttpsDefaults(h => 30 | { 31 | h.ClientCertificateMode = ClientCertificateMode.RequireCertificate; 32 | h.UseLettuceEncrypt(appServices); 33 | }); 34 | }); 35 | } 36 | 37 | // This example shows how to configure Kestrel's address/port binding along with 38 | // enabling Lettuce Encrypt's certificate automation. 39 | if (Environment.GetEnvironmentVariable("CONFIG_KESTREL_VIA_CODE") == "true") 40 | { 41 | webBuilder.PreferHostingUrls(false); 42 | webBuilder.UseKestrel(k => 43 | { 44 | var appServices = k.ApplicationServices; 45 | k.Listen(IPAddress.Any, 443, 46 | o => 47 | o.UseHttps(h => h.UseLettuceEncrypt(appServices))); 48 | }); 49 | } 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /samples/Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Development": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "http://localhost:5001" 10 | }, 11 | "Staging": { 12 | "commandName": "Project", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Staging" 16 | }, 17 | "applicationUrl": "http://localhost:5001;https://localhost:5002" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /samples/Web/Startup.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace Web; 5 | 6 | public class Startup 7 | { 8 | public void ConfigureServices(IServiceCollection services) 9 | { 10 | services.AddLettuceEncrypt(); 11 | } 12 | 13 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 14 | { 15 | if (env.IsDevelopment()) 16 | { 17 | app.UseDeveloperExceptionPage(); 18 | } 19 | 20 | app.UseHttpsRedirection(); 21 | 22 | app.UseRouting(); 23 | 24 | app.UseEndpoints(endpoints => 25 | { 26 | endpoints.MapGet("/", async context => 27 | { 28 | await context.Response.WriteAsync("Hello World!"); 29 | }); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /samples/Web/Web.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /samples/Web/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "LettuceEncrypt": { 3 | // Uncomment to test in the dev environment 4 | // See CONTRIBUTING.md for instructions 5 | 6 | // "DomainNames": [ 7 | // "56638cf5.ngrok.io" 8 | // ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/Web/appsettings.Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "LettuceEncrypt": { 3 | "DomainNames": [ 4 | "example.com", 5 | "www.example.com", 6 | "www2.example.com" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /samples/Web/appsettings.Staging.json: -------------------------------------------------------------------------------- 1 | { 2 | "LettuceEncrypt": { 3 | "DomainNames": [ 4 | "staging.example.com" 5 | ], 6 | "UseStagingServer": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /samples/Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "LettuceEncrypt": "Trace", 6 | "Microsoft": "Warning", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | }, 10 | "AllowedHosts": "*", 11 | "LettuceEncrypt": { 12 | "AcceptTermsOfService": true, 13 | "EmailAddress": "admin@example.com" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Kestrel.Certificates/IServerCertificateSelector.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.AspNetCore.Connections; 6 | 7 | namespace McMaster.AspNetCore.Kestrel.Certificates; 8 | 9 | /// 10 | /// Selects a certificate for incoming TLS connections. 11 | /// 12 | public interface IServerCertificateSelector 13 | { 14 | /// 15 | /// 16 | /// A callback that will be invoked to dynamically select a server certificate. 17 | /// If SNI is not available, then the domainName parameter will be null. 18 | /// 19 | /// 20 | /// If the server certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1). 21 | /// 22 | /// 23 | X509Certificate2? Select(ConnectionContext context, string? domainName); 24 | } 25 | -------------------------------------------------------------------------------- /src/Kestrel.Certificates/KestrelHttpsOptionsExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using McMaster.AspNetCore.Kestrel.Certificates; 5 | using Microsoft.AspNetCore.Server.Kestrel.Https; 6 | 7 | // ReSharper disable once CheckNamespace 8 | namespace Microsoft.AspNetCore.Hosting; 9 | 10 | /// 11 | /// API for configuring Kestrel certificate options 12 | /// 13 | public static class KestrelHttpsOptionsExtensions 14 | { 15 | /// 16 | /// Configure HTTPS certificates dynamically with an implementation of . 17 | /// 18 | /// The HTTPS configuration 19 | /// The server certificate selector. 20 | /// The HTTPS configuration 21 | public static HttpsConnectionAdapterOptions UseServerCertificateSelector( 22 | this HttpsConnectionAdapterOptions httpsOptions, 23 | IServerCertificateSelector certificateSelector) 24 | { 25 | var fallbackSelector = httpsOptions.ServerCertificateSelector; 26 | httpsOptions.ServerCertificateSelector = (connectionContext, domainName) => 27 | { 28 | var primaryCert = certificateSelector.Select(connectionContext!, domainName); 29 | // fallback to the original selector if the injected selector fails to find a certificate. 30 | return primaryCert ?? fallbackSelector?.Invoke(connectionContext, domainName); 31 | }; 32 | 33 | return httpsOptions; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Kestrel.Certificates/McMaster.AspNetCore.Kestrel.Certificates.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | true 6 | enable 7 | true 8 | A class library for managing HTTPS certificates with ASP.NET Core. 9 | 10 | This library includes API for dynamically selecting which HTTPS certificate to use in Kestrel. 11 | 1.0.0 12 | $(VersionPrefix) 13 | $(PackageVersion)-$(VersionSuffix) 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Kestrel.Certificates/PublicAPI.Shipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | McMaster.AspNetCore.Kestrel.Certificates.IServerCertificateSelector 3 | McMaster.AspNetCore.Kestrel.Certificates.IServerCertificateSelector.Select(Microsoft.AspNetCore.Connections.ConnectionContext! context, string? domainName) -> System.Security.Cryptography.X509Certificates.X509Certificate2? 4 | Microsoft.AspNetCore.Hosting.KestrelHttpsOptionsExtensions 5 | static Microsoft.AspNetCore.Hosting.KestrelHttpsOptionsExtensions.UseServerCertificateSelector(this Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions! httpsOptions, McMaster.AspNetCore.Kestrel.Certificates.IServerCertificateSelector! certificateSelector) -> Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions! 6 | -------------------------------------------------------------------------------- /src/Kestrel.Certificates/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | -------------------------------------------------------------------------------- /src/Kestrel.Certificates/releasenotes.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | First stable release! 5 | 6 | 7 | $(PackageReleaseNotes.Trim()) 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/AzureKeyVaultLettuceEncryptOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.ComponentModel.DataAnnotations; 5 | using Azure.Core; 6 | using Azure.Identity; 7 | using LettuceEncrypt.Accounts; 8 | 9 | namespace LettuceEncrypt.Azure; 10 | 11 | /// 12 | /// Options to connect to an Azure KeyVault 13 | /// 14 | public class AzureKeyVaultLettuceEncryptOptions 15 | { 16 | /// 17 | /// Gets or sets the Url for the KeyVault instance. 18 | /// 19 | [Url] 20 | [Required] 21 | public string AzureKeyVaultEndpoint { get; set; } = null!; 22 | 23 | /// 24 | /// Gets or sets the credentials used for connecting to the key vault. If null, will use . 25 | /// 26 | public TokenCredential? Credentials { get; set; } 27 | 28 | /// 29 | /// Gets or sets the name the secret used to store the account information for accessing the certificate authority. 30 | /// This is a JSON string which encodes the information in . 31 | /// If not set, the name defaults to the name of the "le-account-${ACME server hostname}". 32 | /// 33 | [MaxLength(127)] 34 | public string? AccountKeySecretName { get; set; } 35 | } 36 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/AzureLettuceEncryptExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt; 5 | using LettuceEncrypt.Accounts; 6 | using LettuceEncrypt.Azure; 7 | using LettuceEncrypt.Azure.Internal; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection.Extensions; 10 | using Microsoft.Extensions.Options; 11 | 12 | // ReSharper disable once CheckNamespace 13 | namespace Microsoft.Extensions.DependencyInjection; 14 | 15 | /// 16 | /// Extensions to integrate Azure with LettuceEncrypt. 17 | /// 18 | public static class AzureLettuceEncryptExtensions 19 | { 20 | /// 21 | /// Persists certificates to configured key vault. 22 | /// 23 | /// A LettuceEncrypt service builder. 24 | /// The original LettuceEncrypt service builder. 25 | public static ILettuceEncryptServiceBuilder PersistCertificatesToAzureKeyVault( 26 | this ILettuceEncryptServiceBuilder builder) 27 | => builder.PersistCertificatesToAzureKeyVault(_ => { }); 28 | 29 | /// 30 | /// Persists certificates to configured key vault. 31 | /// 32 | /// A LettuceEncrypt service builder. 33 | /// Configuration for KeyVault connections. 34 | /// The original LettuceEncrypt service builder. 35 | public static ILettuceEncryptServiceBuilder PersistCertificatesToAzureKeyVault( 36 | this ILettuceEncryptServiceBuilder builder, 37 | Action configure) 38 | { 39 | var services = builder.Services; 40 | services 41 | .AddSingleton() 42 | .AddSingleton(); 43 | 44 | services.TryAddSingleton(); 45 | services.TryAddSingleton(); 46 | services.TryAddEnumerable( 47 | ServiceDescriptor.Singleton(x => 48 | x.GetRequiredService())); 49 | services.TryAddEnumerable( 50 | ServiceDescriptor.Singleton(x => 51 | x.GetRequiredService())); 52 | 53 | services.AddSingleton>(s => 54 | { 55 | var config = s.GetService(); 56 | return new ConfigureOptions(o => 57 | config?.Bind("LettuceEncrypt:AzureKeyVault", o)); 58 | }); 59 | 60 | services 61 | .AddOptions() 62 | .Configure(configure) 63 | .ValidateDataAnnotations(); 64 | 65 | return builder; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/Internal/AzureKeyVaultAccountStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Text.Json; 5 | using Azure; 6 | using LettuceEncrypt.Accounts; 7 | using LettuceEncrypt.Acme; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace LettuceEncrypt.Azure.Internal; 12 | 13 | internal class AzureKeyVaultAccountStore : IAccountStore 14 | { 15 | private readonly ILogger _logger; 16 | private readonly IOptions _options; 17 | private readonly ICertificateAuthorityConfiguration _certificateAuthority; 18 | private readonly ISecretClientFactory _secretClientFactory; 19 | 20 | public AzureKeyVaultAccountStore( 21 | ILogger logger, 22 | IOptions options, 23 | ISecretClientFactory secretClientFactory, 24 | ICertificateAuthorityConfiguration certificateAuthority) 25 | { 26 | _logger = logger; 27 | _options = options; 28 | _secretClientFactory = secretClientFactory; 29 | _certificateAuthority = certificateAuthority; 30 | } 31 | 32 | public async Task SaveAccountAsync(AccountModel account, CancellationToken cancellationToken) 33 | { 34 | var secretName = GetSecretName(); 35 | _logger.LogTrace("Saving account information to Azure Key Vault as {secretName}", secretName); 36 | var secretValue = JsonSerializer.Serialize(account); 37 | try 38 | { 39 | var secretClient = _secretClientFactory.Create(); 40 | 41 | await secretClient.SetSecretAsync(secretName, secretValue, cancellationToken); 42 | _logger.LogInformation("Saved account information to Azure Key Vault as {secretName}", secretName); 43 | } 44 | catch (Exception ex) 45 | { 46 | _logger.LogError(ex, "Failed to save account information to Azure Key Vault as {secretName}", 47 | secretName); 48 | throw; 49 | } 50 | } 51 | 52 | public async Task GetAccountAsync(CancellationToken cancellationToken) 53 | { 54 | var secretName = GetSecretName(); 55 | 56 | _logger.LogTrace("Searching account information to Azure Key Vault in secret {secretName}", secretName); 57 | 58 | try 59 | { 60 | var secretClient = _secretClientFactory.Create(); 61 | 62 | var secret = await secretClient.GetSecretAsync(secretName, version: null, cancellationToken); 63 | 64 | _logger.LogInformation("Found account key in {secretName}, version {version}", 65 | secret.Value.Name, 66 | secret.Value.Properties.Version); 67 | 68 | return JsonSerializer.Deserialize(secret.Value.Value); 69 | } 70 | catch (RequestFailedException ex) when (ex.Status == 404) 71 | { 72 | _logger.LogInformation("Could not find account information in secret '{secretName}' in Azure Key Vault", 73 | secretName); 74 | return null; 75 | } 76 | catch (Exception ex) 77 | { 78 | _logger.LogError(ex, "Failed to fetch secret '{secretName}' from Azure Key Vault", secretName); 79 | throw; 80 | } 81 | } 82 | 83 | private string GetSecretName() 84 | { 85 | const int MaxLength = 127; 86 | string name; 87 | 88 | var options = _options.Value; 89 | if (!string.IsNullOrEmpty(options.AccountKeySecretName)) 90 | { 91 | name = options.AccountKeySecretName!; 92 | } 93 | else 94 | { 95 | name = _certificateAuthority.AcmeDirectoryUri.Host; 96 | } 97 | 98 | name = "le-account-" + name.Replace(".", "-"); 99 | return name.Length > MaxLength 100 | ? name.Substring(0, MaxLength) 101 | : name; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/Internal/CertificateClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Azure.Identity; 5 | using Azure.Security.KeyVault.Certificates; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace LettuceEncrypt.Azure.Internal; 9 | 10 | internal interface ICertificateClientFactory 11 | { 12 | CertificateClient Create(); 13 | } 14 | 15 | internal class CertificateClientFactory : ICertificateClientFactory 16 | { 17 | private readonly IOptions _options; 18 | 19 | public CertificateClientFactory(IOptions options) 20 | { 21 | _options = options ?? throw new ArgumentNullException(nameof(options)); 22 | } 23 | 24 | public CertificateClient Create() 25 | { 26 | var value = _options.Value; 27 | 28 | if (string.IsNullOrEmpty(value.AzureKeyVaultEndpoint)) 29 | { 30 | throw new ArgumentException("Missing required option: AzureKeyVaultEndpoint"); 31 | } 32 | 33 | var vaultUri = new Uri(value.AzureKeyVaultEndpoint); 34 | var credentials = value.Credentials ?? new DefaultAzureCredential(); 35 | 36 | return new CertificateClient(vaultUri, credentials); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/Internal/SecretClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Azure.Identity; 5 | using Azure.Security.KeyVault.Secrets; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace LettuceEncrypt.Azure.Internal; 9 | 10 | internal interface ISecretClientFactory 11 | { 12 | SecretClient Create(); 13 | } 14 | 15 | internal class SecretClientFactory : ISecretClientFactory 16 | { 17 | private readonly IOptions _options; 18 | 19 | public SecretClientFactory(IOptions options) 20 | { 21 | _options = options ?? throw new ArgumentNullException(nameof(options)); 22 | } 23 | 24 | public SecretClient Create() 25 | { 26 | var value = _options.Value; 27 | 28 | if (string.IsNullOrEmpty(value.AzureKeyVaultEndpoint)) 29 | { 30 | throw new ArgumentException("Missing required option: AzureKeyVaultEndpoint"); 31 | } 32 | 33 | var vaultUri = new Uri(value.AzureKeyVaultEndpoint); 34 | var credentials = value.Credentials ?? new DefaultAzureCredential(); 35 | 36 | return new SecretClient(vaultUri, credentials); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/LettuceEncrypt.Azure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | true 6 | enable 7 | true 8 | Provides API for configuring ASP.NET Core to automatically generate HTTPS certificates and store them in Azure Key Vault. 9 | $(Description) 10 | 11 | See https://nuget.org/packages/LettuceEncrypt for more details. 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Runtime.CompilerServices; 5 | 6 | [assembly: InternalsVisibleTo("LettuceEncrypt.Azure.UnitTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001001df0eba4297c8ffdf114a13714ad787744619dfb18e29191703f6f782d6a09e4a4cac35b8c768cbbd9ade8197bc0f66ec66fabc9071a206c8060af8b7a332236968d3ee44b90bd2f30d0edcb6150555c6f8d988e48234debaf2d427a08d7c06ba1343411142dc8ac996f7f7dbe0e93d13f17a7624db5400510e6144b0fd683b9")] 7 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] 8 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/PublicAPI.Shipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions 3 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.AzureKeyVaultLettuceEncryptOptions() -> void 4 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.Credentials.get -> Azure.Core.TokenCredential? 5 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.Credentials.set -> void 6 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.AzureKeyVaultEndpoint.get -> string! 7 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.AzureKeyVaultEndpoint.set -> void 8 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.AccountKeySecretName.get -> string? 9 | LettuceEncrypt.Azure.AzureKeyVaultLettuceEncryptOptions.AccountKeySecretName.set -> void 10 | Microsoft.Extensions.DependencyInjection.AzureLettuceEncryptExtensions 11 | static Microsoft.Extensions.DependencyInjection.AzureLettuceEncryptExtensions.PersistCertificatesToAzureKeyVault(this LettuceEncrypt.ILettuceEncryptServiceBuilder! builder) -> LettuceEncrypt.ILettuceEncryptServiceBuilder! 12 | static Microsoft.Extensions.DependencyInjection.AzureLettuceEncryptExtensions.PersistCertificatesToAzureKeyVault(this LettuceEncrypt.ILettuceEncryptServiceBuilder! builder, System.Action! configure) -> LettuceEncrypt.ILettuceEncryptServiceBuilder! 13 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | -------------------------------------------------------------------------------- /src/LettuceEncrypt.Azure/releasenotes.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Update to latest Azure dependencies. See also the release notes for the main LettuceEncrypt package. 5 | 6 | 7 | 8 | 9 | First release! This is basically the same as McMaster.AspNetCore.LetsEncrypt.Azure 0.5.0, but has been renamed. 10 | 11 | See https://github.com/natemcmaster/LettuceEncrypt/#Usage for instructions. 12 | 13 | Changes since 0.5.0: 14 | 15 | * Support configuring Azure Key Vault settings from appsettings.json under the 'LettuceEncrypt:AzureKeyVault' section. 16 | * The private key for accessing the account is now stored in the Azure Key Vault as well under a secret 17 | named 'le-account-${acmeserver}'. This ensures certificates can be renewed later using the same account. 18 | 19 | 20 | $(PackageReleaseNotes.Trim()) 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Accounts/AccountModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Text.Json.Serialization; 5 | using Certes; 6 | 7 | namespace LettuceEncrypt.Accounts; 8 | 9 | /// 10 | /// Represents an account with the certificate authority. 11 | /// 12 | public class AccountModel 13 | { 14 | private byte[] _privateKey = Array.Empty(); 15 | 16 | /// 17 | /// A unique identifier. 18 | /// 19 | public int Id { get; set; } 20 | 21 | /// 22 | /// A list of email addresses associated with the account. 23 | /// At least one should be specified. 24 | /// 25 | public string[] EmailAddresses { get; set; } = Array.Empty(); 26 | 27 | /// 28 | /// The private key for the account. 29 | /// This should be DER encoded key content. 30 | /// 31 | public byte[] PrivateKey 32 | { 33 | get => _privateKey; 34 | set 35 | { 36 | _privateKey = value; 37 | Key = KeyFactory.FromDer(value); 38 | } 39 | } 40 | 41 | [JsonIgnore] internal IKey? Key { get; private set; } 42 | } 43 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Accounts/IAccountStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Accounts; 5 | 6 | /// 7 | /// Manages persistence and retrieval of account information. 8 | /// 9 | public interface IAccountStore 10 | { 11 | /// 12 | /// Save account information for reuse later after a server restart. 13 | /// 14 | /// All information in this model should round trip with . 15 | /// 16 | /// 17 | Task SaveAccountAsync(AccountModel account, CancellationToken cancellationToken); 18 | 19 | /// 20 | /// Fetch account information. 21 | /// 22 | /// 23 | /// Should return null if no account could be found. 24 | Task GetAccountAsync(CancellationToken cancellationToken); 25 | } 26 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Acme/ChallengeType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Acme; 5 | 6 | /// 7 | /// An enumeration that represents the various kinds of challenges in the ACME protocol. 8 | /// See https://letsencrypt.org/docs/challenge-types/. 9 | /// 10 | [Flags] 11 | public enum ChallengeType 12 | { 13 | /// 14 | /// The HTTP-01 challenge, which uses a well-known URL on the server and a HTTP request/response. 15 | /// See https://letsencrypt.org/docs/challenge-types/#http-01-challenge 16 | /// 17 | Http01 = 1 << 0, 18 | 19 | /// 20 | /// The TLS-ALPN-01 challenge, which uses an auto-generated, ephemeral certificate in the TLS handshake. 21 | /// See https://letsencrypt.org/docs/challenge-types/#tls-alpn-01 22 | /// 23 | TlsAlpn01 = 1 << 1, 24 | 25 | /// 26 | /// The DNS-01 challenge, which uses TXT record under that domain name. 27 | /// See https://letsencrypt.org/docs/challenge-types/#dns-01-challenge 28 | /// 29 | Dns01 = 1 << 2, 30 | 31 | /// 32 | /// A special flag which represents all known challenge types. 33 | /// 34 | Any = 0xFFFF, 35 | } 36 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Acme/DnsTxtRecordContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Acme; 5 | 6 | /// 7 | /// Default txt record context 8 | /// 9 | public class DnsTxtRecordContext 10 | { 11 | /// 12 | /// default constructor 13 | /// 14 | /// Domain name for the txt record 15 | /// TXT record Value 16 | public DnsTxtRecordContext(string domainName, string txt) 17 | { 18 | DomainName = domainName; 19 | Txt = txt; 20 | } 21 | 22 | /// 23 | /// Domain name for the txt record 24 | /// 25 | public string DomainName { get; } 26 | /// 27 | /// TXT record Value 28 | /// 29 | public string Txt { get; } 30 | } 31 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Acme/EabCredentials.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Acme; 5 | 6 | /// 7 | /// External Account Binding (EAB) account credentials 8 | /// 9 | public class EabCredentials 10 | { 11 | /// 12 | /// Optional key identifier for external account binding 13 | /// 14 | public string? EabKeyId { get; set; } 15 | 16 | /// 17 | /// Optional key for use with external account binding 18 | /// 19 | public string? EabKey { get; set; } 20 | 21 | /// 22 | /// Optional key algorithm e.g HS256, for external account binding 23 | /// 24 | public string? EabKeyAlg { get; set; } 25 | } 26 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Acme/ICertificateAuthorityConfiguration.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Acme; 5 | 6 | /// 7 | /// Provides configuration for the certificate authority which implements the ACME protocol. 8 | /// 9 | public interface ICertificateAuthorityConfiguration 10 | { 11 | /// 12 | /// The base uri of the ACME protocol. 13 | /// 14 | Uri AcmeDirectoryUri { get; } 15 | 16 | /// 17 | /// Certificates passed to certes before building the successfully downloaded certificate, 18 | /// used internally by certes to verify the issuer for authenticity. 19 | /// 20 | /// 21 | /// Lettuce encrypt uses certes internally, while certes depends on BouncyCastle.Cryptography to parse 22 | /// certificates. See https://github.com/bcgit/bc-csharp/blob/830d9b8c7bdfcec511bff0a6cf4a0e8ed568e7c1/crypto/src/x509/X509CertificateParser.cs#L20 23 | /// if you're wondering what certificate formats are supported. 24 | /// 25 | /// 26 | string[] IssuerCertificates => Array.Empty(); 27 | } 28 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Acme/IDnsChallengeProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Acme; 5 | 6 | /// 7 | /// External Dns provider to update for DNS-01 challenge 8 | /// 9 | public interface IDnsChallengeProvider 10 | { 11 | /// 12 | /// call to add record in advance of the validation 13 | /// 14 | /// domain name including _acme-challenge. <YOUR_DOMAIN> 15 | /// TXT value for DNS-01 Challenge 16 | /// A cancellation token. 17 | /// context of added txt record 18 | Task AddTxtRecordAsync(string domainName, string txt, CancellationToken ct = default); 19 | 20 | /// 21 | /// callback to remove dns record after validations 22 | /// 23 | /// context from previous added txt record 24 | /// A cancellation token. 25 | /// 26 | Task RemoveTxtRecordAsync(DnsTxtRecordContext context, CancellationToken ct = default); 27 | } 28 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/FileSystemPersistenceExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Accounts; 5 | using LettuceEncrypt.Acme; 6 | using LettuceEncrypt.Internal; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.DependencyInjection.Extensions; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace LettuceEncrypt; 12 | 13 | /// 14 | /// Extensions for configuring certificate persistence 15 | /// 16 | public static class FileSystemStorageExtensions 17 | { 18 | /// 19 | /// Save certificates and account data to a directory. 20 | /// Certificates are stored in the .pfx (PKCS #12) format in a subdirectory of . 21 | /// Account key information is stored in a JSON format in a different subdirectory of . 22 | /// 23 | /// 24 | /// The root directory for storing information. Information may be stored in subdirectories. 25 | /// Set to null or empty for password-less .pfx files. 26 | /// 27 | public static ILettuceEncryptServiceBuilder PersistDataToDirectory( 28 | this ILettuceEncryptServiceBuilder builder, 29 | DirectoryInfo directory, 30 | string? pfxPassword) 31 | { 32 | if (builder is null) 33 | { 34 | throw new ArgumentNullException(nameof(builder)); 35 | } 36 | 37 | if (directory is null) 38 | { 39 | throw new ArgumentNullException(nameof(directory)); 40 | } 41 | 42 | var otherFileSystemRepoServices = builder 43 | .Services 44 | .Where(d => d.ServiceType == typeof(ICertificateRepository) 45 | && d.ImplementationInstance != null 46 | && d.ImplementationInstance.GetType() == typeof(FileSystemCertificateRepository)); 47 | 48 | foreach (var serviceDescriptor in otherFileSystemRepoServices) 49 | { 50 | var otherRepo = (FileSystemCertificateRepository)serviceDescriptor.ImplementationInstance!; 51 | if (otherRepo.RootDir.Equals(directory)) 52 | { 53 | if (otherRepo.PfxPassword != pfxPassword) 54 | { 55 | throw new ArgumentException($"Another file system repo has been configured for {directory}, but with a different password."); 56 | } 57 | return builder; 58 | } 59 | } 60 | 61 | var implementationInstance = new FileSystemCertificateRepository(directory, pfxPassword); 62 | builder.Services 63 | .AddSingleton(implementationInstance) 64 | .AddSingleton(implementationInstance); 65 | 66 | builder.Services.TryAddSingleton(services => new FileSystemAccountStore(directory, 67 | services.GetRequiredService>(), 68 | services.GetRequiredService())); 69 | 70 | return builder; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/ICertificateRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | namespace LettuceEncrypt; 7 | 8 | /// 9 | /// Manages certificate persistence after it is generated. 10 | /// 11 | public interface ICertificateRepository 12 | { 13 | /// 14 | /// Save the certificate. 15 | /// 16 | /// The certificate, including its private keys. 17 | /// A token which, when canceled, should stop any async operations. 18 | /// A task which completes once the certificate is done saving. 19 | Task SaveAsync(X509Certificate2 certificate, CancellationToken cancellationToken); 20 | } 21 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/ICertificateSource.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | namespace LettuceEncrypt; 7 | 8 | /// 9 | /// Defines a source for certificates. 10 | /// 11 | public interface ICertificateSource 12 | { 13 | /// 14 | /// Gets available certificates from the source. 15 | /// 16 | /// A cancellation token. 17 | /// A collection of certificates. 18 | Task> GetCertificatesAsync(CancellationToken cancellationToken); 19 | } 20 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/ILettuceEncryptServiceBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace LettuceEncrypt; 7 | 8 | /// 9 | /// An interface for building extension methods to extend LettuceEncrypt configuration. 10 | /// 11 | public interface ILettuceEncryptServiceBuilder 12 | { 13 | /// 14 | /// The service collection. 15 | /// 16 | IServiceCollection Services { get; } 17 | } 18 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeCertificateLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal.AcmeStates; 5 | using Microsoft.AspNetCore.Hosting.Server; 6 | using Microsoft.AspNetCore.Server.Kestrel.Core; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Options; 12 | 13 | namespace LettuceEncrypt.Internal; 14 | 15 | /// 16 | /// This starts the ACME state machine, which handles certificate generation and renewal 17 | /// 18 | internal class AcmeCertificateLoader : BackgroundService 19 | { 20 | private readonly IServiceScopeFactory _serviceScopeFactory; 21 | private readonly IOptions _options; 22 | private readonly ILogger _logger; 23 | 24 | private readonly IServer _server; 25 | private readonly IConfiguration _config; 26 | 27 | public AcmeCertificateLoader( 28 | IServiceScopeFactory serviceScopeFactory, 29 | IOptions options, 30 | ILogger logger, 31 | IServer server, 32 | IConfiguration config) 33 | { 34 | _serviceScopeFactory = serviceScopeFactory; 35 | _options = options; 36 | _logger = logger; 37 | _server = server; 38 | _config = config; 39 | } 40 | 41 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 42 | { 43 | if (!_server.GetType().Name.StartsWith(nameof(KestrelServer))) 44 | { 45 | var serverType = _server.GetType().FullName; 46 | _logger.LogWarning( 47 | "LettuceEncrypt can only be used with Kestrel and is not supported on {serverType} servers. Skipping certificate provisioning.", 48 | serverType); 49 | return; 50 | } 51 | 52 | if (_config.GetValue("UseIISIntegration")) 53 | { 54 | _logger.LogWarning( 55 | "LettuceEncrypt does not work with apps hosting in IIS. IIS does not allow for dynamic HTTPS certificate binding." + 56 | "Skipping certificate provisioning."); 57 | return; 58 | } 59 | 60 | // load certificates in the background 61 | if (!LettuceEncryptDomainNamesWereConfigured()) 62 | { 63 | _logger.LogInformation("No domain names were configured"); 64 | return; 65 | } 66 | 67 | using var acmeStateMachineScope = _serviceScopeFactory.CreateScope(); 68 | 69 | try 70 | { 71 | IAcmeState state = acmeStateMachineScope.ServiceProvider.GetRequiredService(); 72 | 73 | while (!stoppingToken.IsCancellationRequested) 74 | { 75 | _logger.LogTrace("ACME state transition: moving to {stateName}", state.GetType().Name); 76 | state = await state.MoveNextAsync(stoppingToken); 77 | } 78 | } 79 | catch (OperationCanceledException) 80 | { 81 | _logger.LogDebug("State machine cancellation requested. Exiting..."); 82 | } 83 | catch (AggregateException ex) when (ex.InnerException != null) 84 | { 85 | _logger.LogError(0, ex.InnerException, "ACME state machine encountered unhandled error"); 86 | } 87 | catch (Exception ex) 88 | { 89 | _logger.LogError(0, ex, "ACME state machine encountered unhandled error"); 90 | } 91 | } 92 | 93 | private bool LettuceEncryptDomainNamesWereConfigured() 94 | => _options.Value.DomainNames 95 | .Any(w => !string.Equals("localhost", w, StringComparison.OrdinalIgnoreCase)); 96 | } 97 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeClient.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes; 5 | using Certes.Acme; 6 | using Certes.Acme.Resource; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace LettuceEncrypt.Internal; 11 | 12 | internal class AcmeClient 13 | { 14 | private readonly AcmeContext _context; 15 | private readonly ILogger _logger; 16 | private readonly IOptions _options; 17 | private readonly IKey _acmeAccountKey; 18 | private IAccountContext? _accountContext; 19 | 20 | public AcmeClient(ILogger logger, IOptions options, Uri directoryUri, IKey acmeAccountKey) 21 | { 22 | _logger = logger; 23 | _options = options; 24 | _acmeAccountKey = acmeAccountKey; 25 | _logger.LogInformation("Using certificate authority {directoryUri}", directoryUri); 26 | _context = new AcmeContext(directoryUri, acmeAccountKey); 27 | } 28 | 29 | public async Task GetAccountAsync() 30 | { 31 | _logger.LogAcmeAction("FetchAccount"); 32 | _accountContext = await _context.Account(); 33 | _logger.LogAcmeAction("FetchAccountDetails", _accountContext); 34 | return await _accountContext.Resource(); 35 | } 36 | 37 | public IKey? GetAccountKey() 38 | { 39 | _logger.LogAcmeAction("GetAccountKey"); 40 | return _acmeAccountKey; 41 | } 42 | 43 | public async Task CreateAccountAsync(string emailAddress) 44 | { 45 | _logger.LogAcmeAction("NewAccount"); 46 | var eabCredentials = _options.Value.EabCredentials; 47 | _accountContext = await _context.NewAccount(emailAddress, termsOfServiceAgreed: true, eabKeyId: eabCredentials.EabKeyId, eabKey: eabCredentials.EabKey, eabKeyAlg: eabCredentials.EabKeyAlg); 48 | 49 | return int.TryParse(_accountContext.Location.Segments.Last(), out var accountId) 50 | ? accountId 51 | : 0; 52 | } 53 | 54 | public async Task GetTermsOfServiceAsync() 55 | { 56 | _logger.LogAcmeAction("FetchTOS"); 57 | return await _context.TermsOfService(); 58 | } 59 | 60 | public async Task AgreeToTermsOfServiceAsync() 61 | { 62 | if (_accountContext == null) 63 | { 64 | throw MissingAccountContext(); 65 | } 66 | _logger.LogAcmeAction("UpdateTOS"); 67 | await _accountContext.Update(agreeTermsOfService: true); 68 | } 69 | 70 | public async Task> GetOrdersAsync() 71 | { 72 | if (_accountContext == null) 73 | { 74 | throw MissingAccountContext(); 75 | } 76 | 77 | _logger.LogAcmeAction("FetchOrderList"); 78 | var orderListContext = await _accountContext.Orders(); 79 | 80 | if (orderListContext == null) 81 | { 82 | return Enumerable.Empty(); 83 | } 84 | _logger.LogAcmeAction("FetchOrderDetails", orderListContext); 85 | return await orderListContext.Orders(); 86 | } 87 | 88 | public async Task CreateOrderAsync(string[] domainNames) 89 | { 90 | _logger.LogAcmeAction("NewOrder"); 91 | return await _context.NewOrder(domainNames); 92 | } 93 | 94 | public async Task GetOrderDetailsAsync(IOrderContext order) 95 | { 96 | _logger.LogAcmeAction("FetchOrderDetails", order); 97 | return await order.Resource(); 98 | } 99 | 100 | public async Task> GetOrderAuthorizations(IOrderContext orderContext) 101 | { 102 | _logger.LogAcmeAction("FetchAuthorizations", orderContext); 103 | return await orderContext.Authorizations(); 104 | } 105 | 106 | public async Task GetAuthorizationAsync(IAuthorizationContext authorizationContext) 107 | { 108 | _logger.LogAcmeAction("FetchAuthorizationDetails", authorizationContext); 109 | return await authorizationContext.Resource(); 110 | } 111 | 112 | public async Task CreateChallengeAsync(IAuthorizationContext authorizationContext, string challengeType) 113 | { 114 | _logger.LogAcmeAction("CreateChallenge", authorizationContext); 115 | return await authorizationContext.Challenge(challengeType); 116 | } 117 | 118 | public async Task ValidateChallengeAsync(IChallengeContext httpChallenge) 119 | { 120 | _logger.LogAcmeAction("ValidateChallenge", httpChallenge); 121 | return await httpChallenge.Validate(); 122 | } 123 | 124 | public async Task GetCertificateAsync(CsrInfo csrInfo, IKey privateKey, IOrderContext order) 125 | { 126 | _logger.LogAcmeAction("GenerateCertificate", order); 127 | return await order.Generate(csrInfo, privateKey); 128 | } 129 | 130 | private static Exception MissingAccountContext() => new InvalidOperationException("Account wasn't initialized yet"); 131 | } 132 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeClientFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes; 5 | using LettuceEncrypt.Acme; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal class AcmeClientFactory 12 | { 13 | private readonly ICertificateAuthorityConfiguration _certificateAuthority; 14 | private readonly ILogger _logger; 15 | private readonly IOptions _options; 16 | 17 | public AcmeClientFactory( 18 | ICertificateAuthorityConfiguration certificateAuthority, 19 | ILogger logger, 20 | IOptions options) 21 | { 22 | _certificateAuthority = certificateAuthority; 23 | _logger = logger; 24 | _options = options; 25 | } 26 | 27 | public AcmeClient Create(IKey acmeAccountKey) 28 | { 29 | var directoryUri = _certificateAuthority.AcmeDirectoryUri; 30 | 31 | return new AcmeClient(_logger, _options, directoryUri, acmeAccountKey); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeStates/AcmeState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace LettuceEncrypt.Internal.AcmeStates; 7 | 8 | internal interface IAcmeState 9 | { 10 | Task MoveNextAsync(CancellationToken cancellationToken); 11 | } 12 | 13 | internal class TerminalState : IAcmeState 14 | { 15 | public static TerminalState Singleton { get; } = new(); 16 | 17 | private TerminalState() { } 18 | 19 | public Task MoveNextAsync(CancellationToken cancellationToken) 20 | { 21 | throw new OperationCanceledException(); 22 | } 23 | } 24 | 25 | internal abstract class AcmeState : IAcmeState 26 | { 27 | private readonly AcmeStateMachineContext _context; 28 | 29 | public AcmeState(AcmeStateMachineContext context) 30 | { 31 | _context = context; 32 | } 33 | 34 | public abstract Task MoveNextAsync(CancellationToken cancellationToken); 35 | 36 | protected T MoveTo() where T : IAcmeState 37 | { 38 | return _context.Services.GetRequiredService(); 39 | } 40 | } 41 | 42 | internal abstract class SyncAcmeState : AcmeState 43 | { 44 | protected SyncAcmeState(AcmeStateMachineContext context) : base(context) 45 | { 46 | } 47 | 48 | public override Task MoveNextAsync(CancellationToken cancellationToken) 49 | { 50 | cancellationToken.ThrowIfCancellationRequested(); 51 | 52 | var next = MoveNext(); 53 | 54 | return Task.FromResult(next); 55 | } 56 | 57 | public abstract IAcmeState MoveNext(); 58 | } 59 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeStates/AcmeStateMachineContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal.AcmeStates; 5 | 6 | internal class AcmeStateMachineContext 7 | { 8 | public IServiceProvider Services { get; } 9 | 10 | public AcmeStateMachineContext(IServiceProvider services) 11 | { 12 | Services = services; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeStates/BeginCertificateCreationState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace LettuceEncrypt.Internal.AcmeStates; 9 | 10 | internal class BeginCertificateCreationState : AcmeState 11 | { 12 | private readonly ILogger _logger; 13 | private readonly IOptions _options; 14 | private readonly AcmeCertificateFactory _acmeCertificateFactory; 15 | private readonly CertificateSelector _selector; 16 | private readonly IEnumerable _certificateRepositories; 17 | 18 | public BeginCertificateCreationState( 19 | AcmeStateMachineContext context, ILogger logger, 20 | IOptions options, AcmeCertificateFactory acmeCertificateFactory, 21 | CertificateSelector selector, IEnumerable certificateRepositories) 22 | : base(context) 23 | { 24 | _logger = logger; 25 | _options = options; 26 | _acmeCertificateFactory = acmeCertificateFactory; 27 | _selector = selector; 28 | _certificateRepositories = certificateRepositories; 29 | } 30 | 31 | public override async Task MoveNextAsync(CancellationToken cancellationToken) 32 | { 33 | var domainNames = _options.Value.DomainNames; 34 | 35 | try 36 | { 37 | var account = await _acmeCertificateFactory.GetOrCreateAccountAsync(cancellationToken); 38 | _logger.LogInformation("Using account {accountId}", account.Id); 39 | 40 | _logger.LogInformation("Creating certificate for {hostname}", 41 | string.Join(",", domainNames)); 42 | 43 | var cert = await _acmeCertificateFactory.CreateCertificateAsync(cancellationToken); 44 | 45 | _logger.LogInformation("Created certificate {subjectName} ({thumbprint})", 46 | cert.Subject, 47 | cert.Thumbprint); 48 | 49 | await SaveCertificateAsync(cert, cancellationToken); 50 | } 51 | catch (Exception ex) 52 | { 53 | _logger.LogError(0, ex, "Failed to automatically create a certificate for {hostname}", domainNames); 54 | throw; 55 | } 56 | 57 | return MoveTo(); 58 | } 59 | 60 | private async Task SaveCertificateAsync(X509Certificate2 cert, CancellationToken cancellationToken) 61 | { 62 | _selector.Add(cert); 63 | 64 | var saveTasks = new List 65 | { 66 | Task.Delay(TimeSpan.FromMinutes(5), cancellationToken) 67 | }; 68 | 69 | var errors = new List(); 70 | foreach (var repo in _certificateRepositories) 71 | { 72 | try 73 | { 74 | saveTasks.Add(repo.SaveAsync(cert, cancellationToken)); 75 | } 76 | catch (Exception ex) 77 | { 78 | // synchronous saves may fail immediately 79 | errors.Add(ex); 80 | } 81 | } 82 | 83 | await Task.WhenAll(saveTasks); 84 | 85 | if (errors.Count > 0) 86 | { 87 | throw new AggregateException("Failed to save cert to repositories", errors); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeStates/CheckForRenewalState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal.IO; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace LettuceEncrypt.Internal.AcmeStates; 9 | 10 | internal class CheckForRenewalState : AcmeState 11 | { 12 | private readonly ILogger _logger; 13 | private readonly IOptions _options; 14 | private readonly CertificateSelector _selector; 15 | private readonly IClock _clock; 16 | 17 | public CheckForRenewalState( 18 | AcmeStateMachineContext context, 19 | ILogger logger, 20 | IOptions options, 21 | CertificateSelector selector, 22 | IClock clock) : base(context) 23 | { 24 | _logger = logger; 25 | _options = options; 26 | _selector = selector; 27 | _clock = clock; 28 | } 29 | 30 | public override async Task MoveNextAsync(CancellationToken cancellationToken) 31 | { 32 | while (!cancellationToken.IsCancellationRequested) 33 | { 34 | var checkPeriod = _options.Value.RenewalCheckPeriod; 35 | var daysInAdvance = _options.Value.RenewDaysInAdvance; 36 | if (!checkPeriod.HasValue || !daysInAdvance.HasValue) 37 | { 38 | _logger.LogInformation("Automatic certificate renewal is not configured. Stopping {service}", 39 | nameof(AcmeCertificateLoader)); 40 | return MoveTo(); 41 | } 42 | 43 | var domainNames = _options.Value.DomainNames; 44 | if (_logger.IsEnabled(LogLevel.Debug)) 45 | { 46 | _logger.LogDebug("Checking certificates' renewals for {hostname}", 47 | string.Join(", ", domainNames)); 48 | } 49 | 50 | foreach (var domainName in domainNames) 51 | { 52 | if (!_selector.TryGet(domainName, out var cert) 53 | || cert == null 54 | || cert.NotAfter <= _clock.Now.DateTime + daysInAdvance.Value) 55 | { 56 | return MoveTo(); 57 | } 58 | } 59 | 60 | await Task.Delay(checkPeriod.Value, cancellationToken); 61 | } 62 | 63 | return MoveTo(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/AcmeStates/ServerStartupState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace LettuceEncrypt.Internal.AcmeStates; 8 | 9 | internal class ServerStartupState : SyncAcmeState 10 | { 11 | private readonly IOptions _options; 12 | private readonly CertificateSelector _selector; 13 | private readonly ILogger _logger; 14 | 15 | public ServerStartupState( 16 | AcmeStateMachineContext context, 17 | IOptions options, 18 | CertificateSelector selector, 19 | ILogger logger) : 20 | base(context) 21 | { 22 | _options = options; 23 | _selector = selector; 24 | _logger = logger; 25 | } 26 | 27 | public override IAcmeState MoveNext() 28 | { 29 | var domainNames = _options.Value.DomainNames; 30 | var hasCertForAllDomains = domainNames.All(_selector.HasCertForDomain); 31 | if (hasCertForAllDomains) 32 | { 33 | _logger.LogDebug("Certificate for {domainNames} already found.", domainNames); 34 | return MoveTo(); 35 | } 36 | 37 | return MoveTo(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/DefaultCertificateAuthorityConfiguration.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes.Acme; 5 | using LettuceEncrypt.Acme; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal class DefaultCertificateAuthorityConfiguration : ICertificateAuthorityConfiguration 12 | { 13 | private readonly IHostEnvironment _env; 14 | private readonly IOptions _options; 15 | 16 | public DefaultCertificateAuthorityConfiguration(IHostEnvironment env, IOptions options) 17 | { 18 | _env = env ?? throw new ArgumentNullException(nameof(env)); 19 | _options = options ?? throw new ArgumentNullException(nameof(options)); 20 | } 21 | 22 | public Uri AcmeDirectoryUri 23 | { 24 | get 25 | { 26 | var options = _options.Value; 27 | var useStaging = options.UseStagingServerExplicitlySet 28 | ? options.UseStagingServer 29 | : _env.IsDevelopment(); 30 | 31 | return useStaging 32 | ? WellKnownServers.LetsEncryptStagingV2 33 | : WellKnownServers.LetsEncryptV2; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/DeveloperCertLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.Extensions.Hosting; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace LettuceEncrypt.Internal; 9 | 10 | /// 11 | /// Loads the ASP.NET Developer certificate 12 | /// /// 13 | internal class DeveloperCertLoader : ICertificateSource 14 | { 15 | // see https://github.com/aspnet/Common/blob/61320f4ecc1a7b60e76ca8fe05cd86c98778f92c/shared/Microsoft.AspNetCore.Certificates.Generation.Sources/CertificateManager.cs#L19-L20 16 | // This is the unique OID for the developer cert generated by VS and the .NET Core CLI 17 | private const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; 18 | private const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; 19 | private readonly IHostEnvironment _environment; 20 | private readonly ILogger _logger; 21 | 22 | public DeveloperCertLoader( 23 | IHostEnvironment environment, 24 | ILogger logger) 25 | { 26 | _environment = environment; 27 | _logger = logger; 28 | } 29 | 30 | public Task> GetCertificatesAsync(CancellationToken cancellationToken) 31 | { 32 | if (!_environment.IsDevelopment()) 33 | { 34 | return Task.FromResult(Enumerable.Empty()); 35 | } 36 | 37 | var certs = FindDeveloperCert(); 38 | 39 | return Task.FromResult(certs); 40 | } 41 | 42 | private IEnumerable FindDeveloperCert() 43 | { 44 | using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); 45 | store.Open(OpenFlags.ReadOnly); 46 | var certs = store.Certificates.Find(X509FindType.FindByExtension, AspNetHttpsOid, validOnly: false); 47 | if (certs.Count == 0) 48 | { 49 | _logger.LogDebug("Could not find the " + AspNetHttpsOidFriendlyName); 50 | } 51 | else 52 | { 53 | _logger.LogDebug("Using the " + AspNetHttpsOidFriendlyName + " for 'localhost' requests"); 54 | 55 | foreach (var cert in certs) 56 | { 57 | yield return cert; 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/Dns01DomainValidator.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes; 5 | using Certes.Acme; 6 | using Certes.Acme.Resource; 7 | using LettuceEncrypt.Acme; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace LettuceEncrypt.Internal; 12 | 13 | internal class Dns01DomainValidator : DomainOwnershipValidator 14 | { 15 | private readonly IDnsChallengeProvider _dnsChallengeProvider; 16 | 17 | public Dns01DomainValidator( 18 | IDnsChallengeProvider dnsChallengeProvider, 19 | IHostApplicationLifetime appLifetime, 20 | AcmeClient client, 21 | ILogger logger, 22 | string domainName 23 | ) : base(appLifetime, client, logger, domainName) 24 | { 25 | _dnsChallengeProvider = dnsChallengeProvider; 26 | } 27 | 28 | public override async Task ValidateOwnershipAsync( 29 | IAuthorizationContext authzContext, 30 | CancellationToken cancellationToken 31 | ) 32 | { 33 | var context = new DnsTxtRecordContext(_domainName, string.Empty); 34 | try 35 | { 36 | context = await PrepareDns01ChallengeResponseAsync(authzContext, _domainName, cancellationToken); 37 | await WaitForChallengeResultAsync(authzContext, cancellationToken); 38 | } 39 | finally 40 | { 41 | // Cleanup 42 | await _dnsChallengeProvider.RemoveTxtRecordAsync(context, cancellationToken); 43 | } 44 | } 45 | 46 | private async Task PrepareDns01ChallengeResponseAsync( 47 | IAuthorizationContext authorizationContext, 48 | string domainName, 49 | CancellationToken cancellationToken 50 | ) 51 | { 52 | cancellationToken.ThrowIfCancellationRequested(); 53 | 54 | var account = _client.GetAccountKey(); 55 | var dnsChallenge = await _client.CreateChallengeAsync(authorizationContext, ChallengeTypes.Dns01); 56 | 57 | var dnsTxt = account.DnsTxt(dnsChallenge.Token); 58 | 59 | var acmeDomain = GetAcmeDnsDomain(domainName); 60 | 61 | var context = await _dnsChallengeProvider.AddTxtRecordAsync(acmeDomain, dnsTxt, cancellationToken); 62 | 63 | _logger.LogTrace("Requesting server to validate DNS challenge"); 64 | await _client.ValidateChallengeAsync(dnsChallenge); 65 | 66 | return context; 67 | } 68 | 69 | private const string DnsAcmePrefix = "_acme-challenge"; 70 | 71 | private string GetAcmeDnsDomain(string domainName) => 72 | $"{DnsAcmePrefix}.{domainName.TrimStart('*')}"; 73 | } 74 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/DomainOwnershipValidator.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes.Acme; 5 | using Certes.Acme.Resource; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal abstract class DomainOwnershipValidator 12 | { 13 | protected readonly AcmeClient _client; 14 | protected readonly ILogger _logger; 15 | protected readonly string _domainName; 16 | protected readonly TaskCompletionSource _appStarted = new(); 17 | 18 | protected DomainOwnershipValidator(IHostApplicationLifetime appLifetime, AcmeClient client, ILogger logger, string domainName) 19 | { 20 | _client = client; 21 | _logger = logger; 22 | _domainName = domainName; 23 | 24 | appLifetime.ApplicationStarted.Register(() => _appStarted.TrySetResult(null)); 25 | if (appLifetime.ApplicationStarted.IsCancellationRequested) 26 | { 27 | _appStarted.TrySetResult(null); 28 | } 29 | } 30 | 31 | public abstract Task ValidateOwnershipAsync(IAuthorizationContext authzContext, CancellationToken cancellationToken); 32 | 33 | protected async Task WaitForChallengeResultAsync(IAuthorizationContext authorizationContext, CancellationToken cancellationToken) 34 | { 35 | var retries = 60; 36 | var delay = TimeSpan.FromSeconds(2); 37 | 38 | while (retries > 0) 39 | { 40 | retries--; 41 | 42 | cancellationToken.ThrowIfCancellationRequested(); 43 | 44 | var authorization = await _client.GetAuthorizationAsync(authorizationContext); 45 | 46 | _logger.LogAcmeAction("GetAuthorization"); 47 | 48 | switch (authorization.Status) 49 | { 50 | case AuthorizationStatus.Valid: 51 | return; 52 | case AuthorizationStatus.Pending: 53 | await Task.Delay(delay, cancellationToken); 54 | continue; 55 | case AuthorizationStatus.Invalid: 56 | throw InvalidAuthorizationError(authorization); 57 | case AuthorizationStatus.Revoked: 58 | throw new InvalidOperationException( 59 | $"The authorization to verify domainName '{_domainName}' has been revoked."); 60 | case AuthorizationStatus.Expired: 61 | throw new InvalidOperationException( 62 | $"The authorization to verify domainName '{_domainName}' has expired."); 63 | case AuthorizationStatus.Deactivated: 64 | default: 65 | throw new ArgumentOutOfRangeException("authorization", 66 | "Unexpected response from server while validating domain ownership."); 67 | } 68 | } 69 | 70 | throw new TimeoutException("Timed out waiting for domain ownership validation."); 71 | } 72 | 73 | private Exception InvalidAuthorizationError(Authorization authorization) 74 | { 75 | var reason = "unknown"; 76 | var domainName = authorization.Identifier.Value; 77 | try 78 | { 79 | var errors = authorization.Challenges.Where(a => a.Error != null).Select(a => a.Error) 80 | .Select(error => $"{error.Type}: {error.Detail}, Code = {error.Status}"); 81 | reason = string.Join("; ", errors); 82 | } 83 | catch 84 | { 85 | _logger.LogTrace("Could not determine reason why validation failed. Response: {resp}", authorization); 86 | } 87 | 88 | _logger.LogError("Failed to validate ownership of domainName '{domainName}'. Reason: {reason}", domainName, 89 | reason); 90 | 91 | return new InvalidOperationException($"Failed to validate ownership of domainName '{domainName}'"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/FileSystemAccountStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Text.Json; 5 | using LettuceEncrypt.Accounts; 6 | using LettuceEncrypt.Acme; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal class FileSystemAccountStore : IAccountStore 12 | { 13 | private readonly DirectoryInfo _accountDir; 14 | private readonly ILogger _logger; 15 | 16 | public FileSystemAccountStore( 17 | ILogger logger, 18 | ICertificateAuthorityConfiguration certificateAuthority) 19 | : this(new DirectoryInfo(AppContext.BaseDirectory), logger, certificateAuthority) 20 | { 21 | } 22 | 23 | public FileSystemAccountStore( 24 | DirectoryInfo rootDirectory, 25 | ILogger logger, 26 | ICertificateAuthorityConfiguration certificateAuthority) 27 | { 28 | _logger = logger; 29 | 30 | var topAccountDir = rootDirectory.CreateSubdirectory("accounts"); 31 | var directoryUri = certificateAuthority.AcmeDirectoryUri; 32 | var subPath = Path.Combine(directoryUri.Authority, directoryUri.LocalPath.Substring(1)); 33 | _accountDir = topAccountDir.CreateSubdirectory(subPath); 34 | } 35 | 36 | public async Task GetAccountAsync(CancellationToken cancellationToken) 37 | { 38 | _logger.LogTrace("Looking for account information in {path}", _accountDir.FullName); 39 | 40 | foreach (var jsonFile in _accountDir.GetFiles("*.json")) 41 | { 42 | _logger.LogTrace("Parsing {path} for account info", jsonFile); 43 | 44 | var accountModel = await Deserialize(jsonFile, cancellationToken); 45 | if (accountModel != null) 46 | { 47 | _logger.LogDebug("Loaded account information from {path}", _accountDir.FullName); 48 | return accountModel; 49 | } 50 | } 51 | 52 | _logger.LogDebug("Could not find account information in {path}", _accountDir.FullName); 53 | return default; 54 | } 55 | 56 | private static async Task Deserialize(FileInfo jsonFile, CancellationToken cancellationToken) 57 | { 58 | using var fileStream = jsonFile.OpenRead(); 59 | var deserializeOptions = new JsonSerializerOptions 60 | { 61 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 62 | }; 63 | 64 | return await JsonSerializer.DeserializeAsync(fileStream, deserializeOptions, 65 | cancellationToken); 66 | } 67 | 68 | public async Task SaveAccountAsync(AccountModel account, CancellationToken cancellationToken) 69 | { 70 | _accountDir.Create(); 71 | 72 | var jsonFile = new FileInfo(Path.Combine(_accountDir.FullName, $"{account.Id}.json")); 73 | _logger.LogTrace("Saving account information to {path}", jsonFile.FullName); 74 | 75 | using var writeStream = jsonFile.OpenWrite(); 76 | var serializerOptions = new JsonSerializerOptions 77 | { 78 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 79 | }; 80 | await JsonSerializer.SerializeAsync(writeStream, account, serializerOptions, cancellationToken); 81 | 82 | _logger.LogDebug("Saved account information to {path}", jsonFile.FullName); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/FileSystemCertificateRepository.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | namespace LettuceEncrypt.Internal; 7 | 8 | internal class FileSystemCertificateRepository : ICertificateRepository, ICertificateSource 9 | { 10 | private readonly DirectoryInfo _certDir; 11 | 12 | public FileSystemCertificateRepository(DirectoryInfo directory, string? pfxPassword) 13 | { 14 | RootDir = directory; 15 | PfxPassword = pfxPassword; 16 | _certDir = directory.CreateSubdirectory("certs"); 17 | } 18 | 19 | public DirectoryInfo RootDir { get; } 20 | public string? PfxPassword { get; } 21 | 22 | public Task> GetCertificatesAsync(CancellationToken cancellationToken) 23 | { 24 | var certs = new List(); 25 | foreach (var file in _certDir.GetFiles("*.pfx")) 26 | { 27 | var cert = new X509Certificate2( 28 | fileName: file.FullName, 29 | password: PfxPassword); 30 | certs.Add(cert); 31 | } 32 | 33 | return Task.FromResult(certs.AsEnumerable()); 34 | } 35 | 36 | public Task SaveAsync(X509Certificate2 certificate, CancellationToken cancellationToken) 37 | { 38 | _certDir.Create(); 39 | 40 | var tmpFile = Path.GetTempFileName(); 41 | File.WriteAllBytes( 42 | tmpFile, 43 | certificate.Export(X509ContentType.Pfx, PfxPassword)); 44 | 45 | var fileName = certificate.Thumbprint + ".pfx"; 46 | var output = Path.Combine(_certDir.FullName, fileName); 47 | 48 | // File.Move is an atomic operation on most operating systems. By writing to a temporary file 49 | // first and then moving it, it avoids potential race conditions with readers. 50 | 51 | File.Move(tmpFile, output); 52 | 53 | return Task.CompletedTask; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/Http01DomainValidator.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes.Acme; 5 | using Certes.Acme.Resource; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal class Http01DomainValidator : DomainOwnershipValidator 12 | { 13 | private readonly IHttpChallengeResponseStore _challengeStore; 14 | 15 | public Http01DomainValidator( 16 | IHttpChallengeResponseStore challengeStore, 17 | IHostApplicationLifetime appLifetime, 18 | AcmeClient client, ILogger logger, string domainName) 19 | : base(appLifetime, client, logger, domainName) 20 | { 21 | _challengeStore = challengeStore; 22 | } 23 | 24 | public override async Task ValidateOwnershipAsync(IAuthorizationContext authzContext, CancellationToken cancellationToken) 25 | { 26 | await PrepareHttpChallengeResponseAsync(authzContext, cancellationToken); 27 | await WaitForChallengeResultAsync(authzContext, cancellationToken); 28 | } 29 | 30 | private async Task PrepareHttpChallengeResponseAsync( 31 | IAuthorizationContext authorizationContext, 32 | CancellationToken cancellationToken) 33 | { 34 | cancellationToken.ThrowIfCancellationRequested(); 35 | if (_client == null) 36 | { 37 | throw new InvalidOperationException(); 38 | } 39 | 40 | var httpChallenge = await _client.CreateChallengeAsync(authorizationContext, ChallengeTypes.Http01); 41 | if (httpChallenge == null) 42 | { 43 | throw new InvalidOperationException( 44 | $"Did not receive challenge information for challenge type {ChallengeTypes.Http01}"); 45 | } 46 | 47 | var keyAuth = httpChallenge.KeyAuthz; 48 | _challengeStore.AddChallengeResponse(httpChallenge.Token, keyAuth); 49 | 50 | _logger.LogTrace("Waiting for server to start accepting HTTP requests"); 51 | await _appStarted.Task; 52 | 53 | _logger.LogTrace("Requesting server to validate HTTP challenge"); 54 | await _client.ValidateChallengeAsync(httpChallenge); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/HttpChallengeResponseMiddleware.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace LettuceEncrypt.Internal; 8 | 9 | internal class HttpChallengeResponseMiddleware : IMiddleware 10 | { 11 | private readonly IHttpChallengeResponseStore _responseStore; 12 | private readonly ILogger _logger; 13 | 14 | public HttpChallengeResponseMiddleware( 15 | IHttpChallengeResponseStore responseStore, 16 | ILogger logger) 17 | { 18 | _responseStore = responseStore; 19 | _logger = logger; 20 | } 21 | 22 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 23 | { 24 | // assumes that this middleware has been mapped 25 | var token = context.Request.Path.ToString(); 26 | if (token.StartsWith("/")) 27 | { 28 | token = token.Substring(1); 29 | } 30 | 31 | if (!_responseStore.TryGetResponse(token, out var value)) 32 | { 33 | await next(context); 34 | return; 35 | } 36 | 37 | _logger.LogDebug("Confirmed challenge request for {token}", token); 38 | 39 | context.Response.ContentLength = value?.Length ?? 0; 40 | context.Response.ContentType = "application/octet-stream"; 41 | await context.Response.WriteAsync(value!, context.RequestAborted); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/HttpChallengeStartupFilter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | 7 | namespace LettuceEncrypt.Internal; 8 | 9 | internal class HttpChallengeStartupFilter : IStartupFilter 10 | { 11 | public Action Configure(Action next) 12 | { 13 | return app => 14 | { 15 | app.UseHttpChallengeResponseMiddleware(); 16 | next(app); 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/IHttpChallengeResponseStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal; 5 | 6 | internal interface IHttpChallengeResponseStore 7 | { 8 | void AddChallengeResponse(string token, string response); 9 | 10 | bool TryGetResponse(string token, out string? value); 11 | } 12 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/IO/IClock.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal.IO; 5 | 6 | internal interface IClock 7 | { 8 | DateTimeOffset Now { get; } 9 | } 10 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/IO/IConsole.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal.IO; 5 | 6 | internal interface IConsole 7 | { 8 | bool IsInputRedirected { get; } 9 | ConsoleColor BackgroundColor { get; set; } 10 | ConsoleColor ForegroundColor { get; set; } 11 | bool CursorVisible { get; set; } 12 | 13 | void WriteLine(string line); 14 | void Write(string line); 15 | void ResetColor(); 16 | string ReadLine(); 17 | } 18 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/IO/PhysicalConsole.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Diagnostics.CodeAnalysis; 5 | 6 | namespace LettuceEncrypt.Internal.IO; 7 | 8 | internal class PhysicalConsole : IConsole 9 | { 10 | public static PhysicalConsole Singleton { get; } = new(); 11 | 12 | private PhysicalConsole() 13 | { 14 | } 15 | 16 | public bool IsInputRedirected => Console.IsInputRedirected; 17 | 18 | public ConsoleColor BackgroundColor 19 | { 20 | get => Console.BackgroundColor; 21 | set => Console.BackgroundColor = value; 22 | } 23 | 24 | public ConsoleColor ForegroundColor 25 | { 26 | get => Console.ForegroundColor; 27 | set => Console.ForegroundColor = value; 28 | } 29 | 30 | [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", 31 | Justification = "Annotation introduced after .NET Core 3.1. Behavior is no different in .NET 6.")] 32 | public bool CursorVisible 33 | { 34 | get => Console.CursorVisible; 35 | set => Console.CursorVisible = value; 36 | } 37 | 38 | public void WriteLine(string line) => Console.WriteLine(line); 39 | public void Write(string line) => Console.Write(line); 40 | public void ResetColor() => Console.ResetColor(); 41 | public string ReadLine() => Console.ReadLine()!; 42 | } 43 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/IO/SystemClock.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal.IO; 5 | 6 | internal class SystemClock : IClock 7 | { 8 | public DateTimeOffset Now => DateTimeOffset.Now; 9 | } 10 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/InMemoryHttpChallengeStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Collections.Concurrent; 5 | 6 | namespace LettuceEncrypt.Internal; 7 | 8 | internal class InMemoryHttpChallengeResponseStore : IHttpChallengeResponseStore 9 | { 10 | private readonly ConcurrentDictionary _values = new(); 11 | 12 | public void AddChallengeResponse(string token, string response) 13 | => _values.AddOrUpdate(token, response, (_, _) => response); 14 | 15 | public bool TryGetResponse(string token, out string? value) 16 | => _values.TryGetValue(token, out value); 17 | } 18 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/KestrelOptionsSetup.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using McMaster.AspNetCore.Kestrel.Certificates; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Server.Kestrel.Core; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal class KestrelOptionsSetup : IConfigureOptions 12 | { 13 | private readonly IServerCertificateSelector _certificateSelector; 14 | private readonly TlsAlpnChallengeResponder _tlsAlpnChallengeResponder; 15 | 16 | public KestrelOptionsSetup(IServerCertificateSelector certificateSelector, TlsAlpnChallengeResponder tlsAlpnChallengeResponder) 17 | { 18 | _certificateSelector = certificateSelector ?? throw new ArgumentNullException(nameof(certificateSelector)); 19 | _tlsAlpnChallengeResponder = tlsAlpnChallengeResponder ?? throw new ArgumentNullException(nameof(tlsAlpnChallengeResponder)); 20 | } 21 | 22 | public void Configure(KestrelServerOptions options) 23 | { 24 | options.ConfigureHttpsDefaults(o => o.UseLettuceEncrypt(_certificateSelector, _tlsAlpnChallengeResponder)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/LettuceEncryptApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal; 5 | 6 | // ReSharper disable once CheckNamespace 7 | namespace Microsoft.AspNetCore.Builder; 8 | 9 | /// 10 | /// Helper methods 11 | /// 12 | internal static class LettuceEncryptApplicationBuilderExtensions 13 | { 14 | /// 15 | /// Adds middleware use to verify domain ownership. 16 | /// 17 | /// The application builder 18 | /// The application builder 19 | public static IApplicationBuilder UseHttpChallengeResponseMiddleware(this IApplicationBuilder app) 20 | { 21 | app.Map("/.well-known/acme-challenge", mapped => 22 | { 23 | mapped.UseMiddleware(); 24 | }); 25 | return app; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/LettuceEncryptServiceBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace LettuceEncrypt.Internal; 7 | 8 | internal class LettuceEncryptServiceBuilder : ILettuceEncryptServiceBuilder 9 | { 10 | public LettuceEncryptServiceBuilder(IServiceCollection services) 11 | { 12 | Services = services ?? throw new ArgumentNullException(nameof(services)); 13 | } 14 | 15 | public IServiceCollection Services { get; } 16 | } 17 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/LoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes.Acme; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace LettuceEncrypt.Internal; 8 | 9 | internal static class LoggerExtensions 10 | { 11 | public static void LogAcmeAction(this ILogger logger, string actionName) 12 | { 13 | if (!logger.IsEnabled(LogLevel.Trace)) 14 | { 15 | return; 16 | } 17 | 18 | logger.LogTrace("ACMEv2 action: {name}", actionName); 19 | } 20 | 21 | public static void LogAcmeAction(this ILogger logger, string actionName, IResourceContext resourceContext) 22 | { 23 | if (!logger.IsEnabled(LogLevel.Trace)) 24 | { 25 | return; 26 | } 27 | 28 | logger.LogTrace("ACMEv2 action: {name}, {resource}", actionName, resourceContext.Location); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/NoOpDnsChallengeProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Acme; 5 | 6 | namespace LettuceEncrypt.Internal; 7 | 8 | internal class NoOpDnsChallengeProvider : IDnsChallengeProvider 9 | { 10 | public Task AddTxtRecordAsync( 11 | string domainName, 12 | string txt, 13 | CancellationToken ct = default 14 | ) => Task.FromResult(new DnsTxtRecordContext(domainName, txt)); 15 | 16 | public Task RemoveTxtRecordAsync(DnsTxtRecordContext context, CancellationToken ct = default) => 17 | Task.CompletedTask; 18 | } 19 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/OptionsValidation.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Acme; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace LettuceEncrypt.Internal; 8 | 9 | internal class OptionsValidation : IValidateOptions 10 | { 11 | public ValidateOptionsResult Validate(string name, LettuceEncryptOptions options) 12 | { 13 | if (options.AllowedChallengeTypes == ChallengeType.Dns01) 14 | return ValidateOptionsResult.Success; 15 | 16 | foreach (var dnsName in options.DomainNames) 17 | { 18 | if (dnsName.Contains('*')) 19 | { 20 | return ValidateOptionsResult.Fail($"Cannot use '*' in domain name '{dnsName}'. Wildcard domains are not supported."); 21 | } 22 | } 23 | 24 | return ValidateOptionsResult.Success; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/PfxBuilder/IPfxBuilder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal.PfxBuilder; 5 | 6 | internal interface IPfxBuilder 7 | { 8 | void AddIssuer(byte[] certificate); 9 | 10 | byte[] Build(string friendlyName, string password); 11 | } 12 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/PfxBuilder/IPfxBuilderFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes; 5 | using Certes.Acme; 6 | 7 | namespace LettuceEncrypt.Internal.PfxBuilder; 8 | 9 | internal interface IPfxBuilderFactory 10 | { 11 | IPfxBuilder FromChain(CertificateChain certificateChain, IKey certKey); 12 | } 13 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/PfxBuilder/PfxBuilderFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes; 5 | using Certes.Acme; 6 | 7 | namespace LettuceEncrypt.Internal.PfxBuilder; 8 | 9 | internal sealed class PfxBuilderFactory : IPfxBuilderFactory 10 | { 11 | public IPfxBuilder FromChain(CertificateChain certificateChain, IKey certKey) 12 | => new PfxBuilderWrapper(certificateChain.ToPfx(certKey)); 13 | } 14 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/PfxBuilder/PfxBuilderWrapper.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | namespace LettuceEncrypt.Internal.PfxBuilder; 5 | 6 | internal sealed class PfxBuilderWrapper : IPfxBuilder 7 | { 8 | private readonly Certes.Pkcs.PfxBuilder _pfxBuilder; 9 | 10 | public PfxBuilderWrapper(Certes.Pkcs.PfxBuilder pfxBuilder) 11 | { 12 | _pfxBuilder = pfxBuilder; 13 | } 14 | 15 | public void AddIssuer(byte[] certificate) 16 | => _pfxBuilder.AddIssuer(certificate); 17 | 18 | public byte[] Build(string friendlyName, string password) 19 | => _pfxBuilder.Build(friendlyName, password); 20 | } 21 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/StartupCertificateLoader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | namespace LettuceEncrypt.Internal; 8 | 9 | internal class StartupCertificateLoader : IHostedService 10 | { 11 | private readonly IEnumerable _certSources; 12 | private readonly CertificateSelector _selector; 13 | 14 | public StartupCertificateLoader( 15 | IEnumerable certSources, 16 | CertificateSelector selector) 17 | { 18 | _certSources = certSources; 19 | _selector = selector; 20 | } 21 | 22 | public async Task StartAsync(CancellationToken cancellationToken) 23 | { 24 | var allCerts = new List(); 25 | foreach (var certSource in _certSources) 26 | { 27 | var certs = await certSource.GetCertificatesAsync(cancellationToken); 28 | allCerts.AddRange(certs); 29 | } 30 | 31 | // Add newer certificates first. This avoid potentially unnecessary cert validations on older certificates 32 | foreach (var cert in allCerts.OrderByDescending(c => c.NotAfter)) 33 | { 34 | _selector.Add(cert); 35 | } 36 | } 37 | 38 | public Task StopAsync(CancellationToken cancellationToken) 39 | => Task.CompletedTask; 40 | } 41 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/TermsOfServiceChecker.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal.IO; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace LettuceEncrypt.Internal; 9 | 10 | internal class TermsOfServiceChecker 11 | { 12 | private readonly IConsole _console; 13 | private readonly IOptions _options; 14 | private readonly ILogger _logger; 15 | 16 | public TermsOfServiceChecker( 17 | IConsole console, 18 | IOptions options, 19 | ILogger logger) 20 | { 21 | _console = console; 22 | _options = options; 23 | _logger = logger; 24 | } 25 | 26 | public void EnsureTermsAreAccepted(Uri termsOfServiceUri) 27 | { 28 | if (_options.Value.AcceptTermsOfService) 29 | { 30 | _logger.LogTrace("Terms of service has been accepted per configuration options"); 31 | return; 32 | } 33 | 34 | if (!_console.IsInputRedirected) 35 | { 36 | _console.BackgroundColor = ConsoleColor.DarkBlue; 37 | _console.ForegroundColor = ConsoleColor.White; 38 | _console.WriteLine("By proceeding, you must agree with the following terms of service:"); 39 | _console.WriteLine(termsOfServiceUri.ToString()); 40 | _console.Write("Do you accept? [Y/n] "); 41 | _console.ResetColor(); 42 | try 43 | { 44 | _console.CursorVisible = true; 45 | } 46 | catch { } 47 | 48 | var result = _console.ReadLine().Trim(); 49 | 50 | try 51 | { 52 | _console.CursorVisible = false; 53 | } 54 | catch { } 55 | 56 | if (string.IsNullOrEmpty(result) 57 | || string.Equals("y", result, StringComparison.OrdinalIgnoreCase) 58 | || string.Equals("yes", result, StringComparison.OrdinalIgnoreCase)) 59 | { 60 | return; 61 | } 62 | } 63 | 64 | _logger.LogError("You must accept the terms of service to continue."); 65 | throw new InvalidOperationException("Could not automatically accept the terms of service"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/TlsAlpn01DomainValidator.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes.Acme; 5 | using Certes.Acme.Resource; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace LettuceEncrypt.Internal; 10 | 11 | internal class TlsAlpn01DomainValidator : DomainOwnershipValidator 12 | { 13 | private readonly TlsAlpnChallengeResponder _tlsAlpnChallengeResponder; 14 | 15 | public TlsAlpn01DomainValidator(TlsAlpnChallengeResponder tlsAlpnChallengeResponder, 16 | IHostApplicationLifetime appLifetime, 17 | AcmeClient client, ILogger logger, string domainName) : base(appLifetime, client, logger, domainName) 18 | { 19 | _tlsAlpnChallengeResponder = tlsAlpnChallengeResponder; 20 | } 21 | 22 | public override async Task ValidateOwnershipAsync(IAuthorizationContext authzContext, CancellationToken cancellationToken) 23 | { 24 | try 25 | { 26 | await PrepareTlsAlpnChallengeResponseAsync(authzContext, _domainName, cancellationToken); 27 | await WaitForChallengeResultAsync(authzContext, cancellationToken); 28 | } 29 | finally 30 | { 31 | // cleanup after authorization is done to skip unnecessary cert lookup on all incoming SSL connections 32 | _tlsAlpnChallengeResponder.DiscardChallenge(_domainName); 33 | } 34 | } 35 | 36 | private async Task PrepareTlsAlpnChallengeResponseAsync( 37 | IAuthorizationContext authorizationContext, 38 | string domainName, 39 | CancellationToken cancellationToken) 40 | { 41 | cancellationToken.ThrowIfCancellationRequested(); 42 | 43 | var tlsAlpnChallenge = await _client.CreateChallengeAsync(authorizationContext, ChallengeTypes.TlsAlpn01); 44 | 45 | _tlsAlpnChallengeResponder.PrepareChallengeCert(domainName, tlsAlpnChallenge.KeyAuthz); 46 | 47 | _logger.LogTrace("Waiting for server to start accepting HTTP requests"); 48 | await _appStarted.Task; 49 | 50 | _logger.LogTrace("Requesting server to validate TLS/ALPN challenge"); 51 | await _client.ValidateChallengeAsync(tlsAlpnChallenge); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/TlsAlpnChallengeResponder.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Net.Security; 5 | using System.Runtime.InteropServices; 6 | using System.Security.Cryptography; 7 | using System.Security.Cryptography.X509Certificates; 8 | using System.Text; 9 | using LettuceEncrypt.Acme; 10 | using LettuceEncrypt.Internal.IO; 11 | using Microsoft.AspNetCore.Connections; 12 | using Microsoft.Extensions.Logging; 13 | using Microsoft.Extensions.Options; 14 | using Org.BouncyCastle.Asn1; 15 | 16 | namespace LettuceEncrypt.Internal; 17 | 18 | /// 19 | /// Implements https://tools.ietf.org/html/rfc8737. This validates domain ownership by responding to 20 | /// TLS requests using a special self-signed certificate. 21 | /// 22 | internal class TlsAlpnChallengeResponder 23 | { 24 | // See RFC8737 section 6.1 25 | private static readonly Oid s_acmeExtensionOid = new("1.3.6.1.5.5.7.1.31"); 26 | private const string ProtocolName = "acme-tls/1"; 27 | private static readonly SslApplicationProtocol s_acmeTlsProtocol = new(ProtocolName); 28 | private readonly IClock _clock; 29 | private readonly ILogger _logger; 30 | private readonly IOptions _options; 31 | private readonly CertificateSelector _certificateSelector; 32 | private int _openChallenges = 0; 33 | 34 | public TlsAlpnChallengeResponder( 35 | IOptions options, 36 | CertificateSelector certificateSelector, 37 | IClock clock, 38 | ILogger logger) 39 | { 40 | _options = options ?? throw new ArgumentNullException(nameof(options)); 41 | _certificateSelector = certificateSelector ?? throw new ArgumentNullException(nameof(certificateSelector)); 42 | _clock = clock ?? throw new ArgumentNullException(nameof(clock)); 43 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 44 | } 45 | 46 | public bool IsEnabled => _options.Value.AllowedChallengeTypes.HasFlag(ChallengeType.TlsAlpn01); 47 | 48 | public void OnSslAuthenticate(ConnectionContext context, SslServerAuthenticationOptions options) 49 | { 50 | if (_openChallenges > 0) 51 | { 52 | (options.ApplicationProtocols ??= new List()).Add(s_acmeTlsProtocol); 53 | } 54 | } 55 | 56 | /// 57 | /// Generates a self-signed cert per RFC 8737 spec. 58 | /// 59 | /// the domain name 60 | /// token to be included in self-signed cert 61 | public void PrepareChallengeCert(string domainName, string keyAuthorization) 62 | { 63 | _logger.LogTrace("Creating ALPN self-signed cert for {domainName} and key authz {keyAuth}", 64 | domainName, keyAuthorization); 65 | 66 | var key = RSA.Create(2048); 67 | var csr = new CertificateRequest( 68 | "CN=" + domainName, 69 | key, 70 | HashAlgorithmName.SHA512, 71 | RSASignaturePadding.Pkcs1); 72 | 73 | /* 74 | RFC 8737 Section 3 75 | 76 | The client prepares for validation by constructing a self-signed 77 | certificate that MUST contain an acmeIdentifier extension and a 78 | subjectAlternativeName extension [RFC5280]. The 79 | subjectAlternativeName extension MUST contain a single dNSName entry 80 | where the value is the domain name being validated. The 81 | acmeIdentifier extension MUST contain the SHA-256 digest [FIPS180-4] 82 | of the key authorization [RFC8555] for the challenge. The 83 | acmeIdentifier extension MUST be critical so that the certificate 84 | isn't inadvertently used by non-ACME software. 85 | */ 86 | 87 | // adds subjectAlternativeName 88 | var sanBuilder = new SubjectAlternativeNameBuilder(); 89 | sanBuilder.AddDnsName(domainName); 90 | csr.CertificateExtensions.Add(sanBuilder.Build()); 91 | 92 | // adds acmeIdentifier extension (critical = true) 93 | using var sha256 = SHA256.Create(); 94 | var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(keyAuthorization)); 95 | var extensionData = new DerOctetString(hash).GetDerEncoded(); 96 | var acmeIdentifierExtension = new X509Extension(s_acmeExtensionOid, extensionData, critical: true); 97 | csr.CertificateExtensions.Add(acmeIdentifierExtension); 98 | 99 | // This cert is ephemeral and does not need to be stored for reuse later 100 | var cert = csr.CreateSelfSigned(_clock.Now.AddDays(-1), _clock.Now.AddDays(1)); 101 | 102 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 103 | { 104 | // SSLStream on Windows throws with ephemeral key sets 105 | // workaround from https://github.com/dotnet/runtime/issues/23749#issuecomment-388231655 106 | var originalCert = cert; 107 | cert = new X509Certificate2(cert.Export(X509ContentType.Pkcs12)); 108 | originalCert.Dispose(); 109 | } 110 | 111 | Interlocked.Increment(ref _openChallenges); 112 | _certificateSelector.AddChallengeCert(cert); 113 | } 114 | 115 | public void DiscardChallenge(string domainName) 116 | { 117 | Interlocked.Decrement(ref _openChallenges); 118 | 119 | _logger.LogTrace("Clearing ALPN cert for {domainName}", domainName); 120 | 121 | _certificateSelector.ClearChallengeCert(domainName); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Internal/X509CertStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace LettuceEncrypt.Internal; 9 | 10 | internal class X509CertStore : ICertificateSource, ICertificateRepository, IDisposable 11 | { 12 | private readonly X509Store _store; 13 | private readonly IOptions _options; 14 | private readonly ILogger _logger; 15 | 16 | public bool AllowInvalidCerts { get; set; } 17 | 18 | public X509CertStore(IOptions options, ILogger logger) 19 | { 20 | _store = new X509Store(StoreName.My, StoreLocation.CurrentUser); 21 | _store.Open(OpenFlags.ReadWrite); 22 | _options = options; 23 | _logger = logger; 24 | } 25 | 26 | public Task> GetCertificatesAsync(CancellationToken cancellationToken) 27 | { 28 | var domainNames = new HashSet(_options.Value.DomainNames); 29 | var result = new List(); 30 | var certs = _store.Certificates.Find(X509FindType.FindByTimeValid, 31 | DateTime.Now, 32 | validOnly: !AllowInvalidCerts); 33 | 34 | foreach (var cert in certs) 35 | { 36 | if (!cert.HasPrivateKey) 37 | { 38 | continue; 39 | } 40 | 41 | foreach (var dnsName in X509CertificateHelpers.GetAllDnsNames(cert)) 42 | { 43 | if (domainNames.Contains(dnsName)) 44 | { 45 | result.Add(cert); 46 | break; 47 | } 48 | } 49 | } 50 | 51 | return Task.FromResult(result.AsEnumerable()); 52 | } 53 | 54 | public Task SaveAsync(X509Certificate2 certificate, CancellationToken cancellationToken) 55 | { 56 | try 57 | { 58 | _store.Add(certificate); 59 | } 60 | catch (Exception ex) 61 | { 62 | _logger.LogError(0, ex, "Failed to save certificate to store"); 63 | throw; 64 | } 65 | 66 | return Task.CompletedTask; 67 | } 68 | 69 | public void Dispose() 70 | { 71 | _store.Close(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/KeyAlgorithm.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | // ReSharper disable InconsistentNaming 5 | 6 | namespace LettuceEncrypt; 7 | 8 | /// 9 | /// The supported algorithms. 10 | /// 11 | public enum KeyAlgorithm 12 | { 13 | /// 14 | /// RSASSA-PKCS1-v1_5 using SHA-256. 15 | /// 16 | RS256 = 0, 17 | 18 | /// 19 | /// ECDSA using P-256 and SHA-256. 20 | /// 21 | ES256 = 1, 22 | 23 | /// 24 | /// ECDSA using P-384 and SHA-384. 25 | /// 26 | ES384 = 2, 27 | 28 | /// 29 | /// ECDSA using P-521 and SHA-512. 30 | /// 31 | ES512 = 3 32 | } 33 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/LettuceEncrypt.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | true 6 | enable 7 | true 8 | Provides API for configuring ASP.NET Core to automatically generate HTTPS certificates. 9 | $(Description) 10 | 11 | This configures your server to use the ACME protocol to connect with a certificate authority (CA), 12 | such as Let's Encrypt (https://letsencrypt.org), to verify ownership of your domain name 13 | and generate a HTTPS certificate. This happens automatically when the server starts up, and will 14 | renew the certificate automatically when the expiration date is near. 15 | 16 | This only works with Kestrel, which is the default server configuration for ASP.NET Core projects. Other servers, such as IIS and nginx, are not supported. 17 | 18 | README.md 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/LettuceEncryptKestrelHttpsOptionsExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal; 5 | using McMaster.AspNetCore.Kestrel.Certificates; 6 | using Microsoft.AspNetCore.Server.Kestrel.Https; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | // ReSharper disable once CheckNamespace 10 | namespace Microsoft.AspNetCore.Hosting; 11 | 12 | /// 13 | /// Methods for configuring Kestrel. 14 | /// 15 | public static class LettuceEncryptKestrelHttpsOptionsExtensions 16 | { 17 | private const string MissingServicesMessage = 18 | "Missing required LettuceEncrypt services. Did you call '.AddLettuceEncrypt()' to add these your DI container?"; 19 | 20 | /// 21 | /// Configured LettuceEncrypt on this HTTPS endpoint for Kestrel. 22 | /// 23 | /// Kestrel's HTTPS configuration. 24 | /// 25 | /// The original HTTPS options with some required settings added to it. 26 | /// 27 | /// Raised if 28 | /// has not been used to add required services to the application service provider. 29 | /// 30 | public static HttpsConnectionAdapterOptions UseLettuceEncrypt( 31 | this HttpsConnectionAdapterOptions httpsOptions, 32 | IServiceProvider applicationServices) 33 | { 34 | var selector = applicationServices.GetService(); 35 | 36 | if (selector is null) 37 | { 38 | throw new InvalidOperationException(MissingServicesMessage); 39 | } 40 | 41 | var tlsResponder = applicationServices.GetService(); 42 | if (tlsResponder is null) 43 | { 44 | throw new InvalidOperationException(MissingServicesMessage); 45 | } 46 | 47 | return httpsOptions.UseLettuceEncrypt(selector, tlsResponder); 48 | } 49 | 50 | internal static HttpsConnectionAdapterOptions UseLettuceEncrypt( 51 | this HttpsConnectionAdapterOptions httpsOptions, 52 | IServerCertificateSelector selector, 53 | TlsAlpnChallengeResponder tlsAlpnChallengeResponder) 54 | { 55 | // Check if this handler is already set. If so, chain our handler before it. 56 | var otherHandler = httpsOptions.OnAuthenticate; 57 | httpsOptions.OnAuthenticate = (ctx, options) => 58 | { 59 | tlsAlpnChallengeResponder.OnSslAuthenticate(ctx, options); 60 | otherHandler?.Invoke(ctx, options); 61 | }; 62 | 63 | httpsOptions.UseServerCertificateSelector(selector); 64 | return httpsOptions; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/LettuceEncryptOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using LettuceEncrypt.Acme; 6 | 7 | namespace LettuceEncrypt; 8 | 9 | /// 10 | /// Options for configuring an ACME server to automatically generate HTTPS certificates. 11 | /// 12 | public class LettuceEncryptOptions 13 | { 14 | private string[] _domainNames = Array.Empty(); 15 | private bool? _useStagingServer; 16 | 17 | /// 18 | /// The domain names for which to generate certificates. 19 | /// 20 | public string[] DomainNames 21 | { 22 | get => _domainNames; 23 | set => _domainNames = value ?? throw new ArgumentNullException(nameof(value)); 24 | } 25 | 26 | /// 27 | /// Indicate that you agree with ACME server's terms of service. 28 | /// 29 | public bool AcceptTermsOfService { get; set; } 30 | 31 | /// 32 | /// The email address used to register with the certificate authority. 33 | /// 34 | public string EmailAddress { get; set; } = string.Empty; 35 | 36 | /// 37 | /// Use Let's Encrypt staging server. 38 | /// 39 | /// This is recommended during development of the application and is automatically enabled 40 | /// if the hosting environment name is 'Development'. 41 | /// 42 | /// 43 | public bool UseStagingServer 44 | { 45 | get => _useStagingServer ?? false; 46 | set => _useStagingServer = value; 47 | } 48 | 49 | internal bool UseStagingServerExplicitlySet => _useStagingServer.HasValue; 50 | 51 | /// 52 | /// Additional issuers passed to certes before building the successfully downloaded certificate, 53 | /// used internally by certes to verify the issuer for authenticity. 54 | /// 55 | /// This is useful especially when using a staging server (e.g. for integration tests) with a root certificate 56 | /// that is not part of certes' embedded resources. 57 | /// See https://github.com/fszlin/certes/tree/v3.0.0/src/Certes/Resources/Certificates for context. 58 | /// 59 | /// 60 | /// 61 | /// Lettuce encrypt uses certes internally, while certes depends on BouncyCastle.Cryptography to parse 62 | /// certificates. See https://github.com/bcgit/bc-csharp/blob/830d9b8c7bdfcec511bff0a6cf4a0e8ed568e7c1/crypto/src/x509/X509CertificateParser.cs#L20 63 | /// if you're wondering what certificate formats are supported. 64 | /// 65 | public string[] AdditionalIssuers { get; set; } = Array.Empty(); 66 | 67 | /// 68 | /// A certificate to use if a certificates cannot be created automatically. 69 | /// 70 | /// This can be null if there is not fallback certificate. 71 | /// 72 | /// 73 | public X509Certificate2? FallbackCertificate { get; set; } 74 | 75 | /// 76 | /// How long before certificate expiration will be renewal attempted. 77 | /// Set to null to disable automatic renewal. 78 | /// 79 | public TimeSpan? RenewDaysInAdvance { get; set; } = TimeSpan.FromDays(30); 80 | 81 | /// 82 | /// How often will be certificates checked for renewal 83 | /// 84 | public TimeSpan? RenewalCheckPeriod { get; set; } = TimeSpan.FromDays(1); 85 | 86 | /// 87 | /// The asymmetric algorithm used for generating a private key for certificates: RS256, ES256, ES384, ES512 88 | /// 89 | public KeyAlgorithm KeyAlgorithm { get; set; } = KeyAlgorithm.ES256; 90 | 91 | /// 92 | /// The key size used for generating a private key for certificates 93 | /// 94 | public int? KeySize { get; set; } 95 | 96 | /// 97 | /// Specifies which kinds of ACME challenges LettuceEncrypt can use to verify domain ownership. 98 | /// Defaults to . 99 | /// 100 | public ChallengeType AllowedChallengeTypes { get; set; } = ChallengeType.Any; 101 | 102 | /// 103 | /// Optional EAB (External Account Binding) account credentials used for creating new account. 104 | /// 105 | public EabCredentials EabCredentials { get; set; } = new(); 106 | } 107 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/LettuceEncryptServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt; 5 | using LettuceEncrypt.Acme; 6 | using LettuceEncrypt.Internal; 7 | using LettuceEncrypt.Internal.AcmeStates; 8 | using LettuceEncrypt.Internal.IO; 9 | using LettuceEncrypt.Internal.PfxBuilder; 10 | using McMaster.AspNetCore.Kestrel.Certificates; 11 | using Microsoft.AspNetCore.Hosting; 12 | using Microsoft.AspNetCore.Server.Kestrel.Core; 13 | using Microsoft.Extensions.Configuration; 14 | using Microsoft.Extensions.DependencyInjection.Extensions; 15 | using Microsoft.Extensions.Hosting; 16 | using Microsoft.Extensions.Options; 17 | 18 | // ReSharper disable once CheckNamespace 19 | namespace Microsoft.Extensions.DependencyInjection; 20 | 21 | /// 22 | /// Helper methods for configuring Lettuce Encrypt services. 23 | /// 24 | public static class LettuceEncryptServiceCollectionExtensions 25 | { 26 | /// 27 | /// Add services that will automatically generate HTTPS certificates for this server. 28 | /// By default, this uses Let's Encrypt (https://letsencrypt.org/). 29 | /// 30 | /// 31 | /// 32 | public static ILettuceEncryptServiceBuilder AddLettuceEncrypt(this IServiceCollection services) 33 | => services.AddLettuceEncrypt(_ => { }); 34 | 35 | /// 36 | /// Add services that will automatically generate HTTPS certificates for this server. 37 | /// By default, this uses Let's Encrypt (https://letsencrypt.org/). 38 | /// 39 | /// 40 | /// A callback to configure options. 41 | /// 42 | public static ILettuceEncryptServiceBuilder AddLettuceEncrypt(this IServiceCollection services, 43 | Action configure) 44 | { 45 | services.AddTransient, KestrelOptionsSetup>(); 46 | 47 | services.TryAddSingleton(); 48 | 49 | services.TryAddEnumerable(ServiceDescriptor.Singleton, OptionsValidation>()); 50 | 51 | services 52 | .AddSingleton() 53 | .AddSingleton(s => s.GetRequiredService()) 54 | .AddSingleton(PhysicalConsole.Singleton) 55 | .AddSingleton() 56 | .AddSingleton() 57 | .AddSingleton() 58 | .AddSingleton() 59 | .AddSingleton() 60 | .AddSingleton() 61 | .AddSingleton() 62 | .AddSingleton() 63 | .AddSingleton() 64 | .AddSingleton(x => x.GetRequiredService()) 65 | .AddSingleton(x => x.GetRequiredService()) 66 | .AddSingleton() 67 | .AddSingleton() 68 | .AddSingleton() 69 | .AddSingleton(); 70 | 71 | services.AddSingleton>(s => 72 | { 73 | var config = s.GetService(); 74 | return new ConfigureOptions(options => config?.Bind("LettuceEncrypt", options)); 75 | }); 76 | 77 | services.Configure(configure); 78 | 79 | // The state machine should run in its own scope 80 | services.AddScoped(); 81 | 82 | services.AddSingleton(TerminalState.Singleton); 83 | 84 | // States should always be transient 85 | services 86 | .AddTransient() 87 | .AddTransient() 88 | .AddTransient(); 89 | 90 | // PfxBuilderFactory is stateless, so there's no need for a transient registration 91 | services.AddSingleton(); 92 | 93 | return new LettuceEncryptServiceBuilder(services); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Runtime.CompilerServices; 5 | 6 | [assembly: InternalsVisibleTo("LettuceEncrypt.UnitTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001001df0eba4297c8ffdf114a13714ad787744619dfb18e29191703f6f782d6a09e4a4cac35b8c768cbbd9ade8197bc0f66ec66fabc9071a206c8060af8b7a332236968d3ee44b90bd2f30d0edcb6150555c6f8d988e48234debaf2d427a08d7c06ba1343411142dc8ac996f7f7dbe0e93d13f17a7624db5400510e6144b0fd683b9")] 7 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] 8 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | -------------------------------------------------------------------------------- /src/LettuceEncrypt/releasenotes.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | New features: 5 | 6 | * Add support for wildcard domains by using DNS challenges to validate domain ownership 7 | * See https://github.com/natemcmaster/LettuceEncrypt#when-using-dns-01 for details. 8 | 9 | * Add option (#279) and API (#281) to configure additional valid issuers 10 | * This is done by configuring certificates as strings. These are passed to certes internally when verifying the issuer. 11 | Those certificates must be parseable by https://github.com/bcgit/bc-csharp/blob/830d9b8c7bdfcec511bff0a6cf4a0e8ed568e7c1/crypto/src/x509/X509CertificateParser.cs#L20 12 | For details, see https://github.com/fszlin/certes/blob/ffa00c6061b49de17901df0cd997cc7531e1607e/src/Certes/Pkcs/PfxBuilder.cs#L66 13 | 14 | Other: 15 | 16 | * Added support for .NET 6 (#280) 17 | * Dropped support for .NET Standard 2.0 and .NET Core 3.1 (#280) 18 | 19 | 20 | New features: 21 | * add API for configuring EAB (External Account Binding) credentials 22 | 23 | Other: 24 | * Update Certes dependency to v3 25 | * Dropped support for .NET 5 26 | 27 | 28 | New features: 29 | * Add API for controlling whether HTTP-01, TLS-ALPN-01, or both challenge types are used (#197) 30 | * create abstraction for SNI certificates in Kestrel (#108) 31 | * add API to configure LettuceEncrypt when also calling 'UseKestrel' to configure its HTTPS defaults or endpoints (#109) 32 | 33 | Bug fixes: 34 | * workaround bug in Windows SSL stream when generating temporary self-signed certs (#110) 35 | * fix race condition causing domain validation to valid sometimes. Run TLS-ALPN-01 first then HTTP-01 (if needed). Don't run in parallel (#198) 36 | * Don't unset other HTTPS adapters which configure a OnAuthenticate callback (#199) 37 | 38 | Other: 39 | * Update package to target .NET Core 3.1 as 3.0 is no longer supported by Microsoft 40 | 41 | Fixes in patch 1.1.1: 42 | * Fix infinite loop waiting for verification of domain ownership 43 | * Check for certificates that new renewal immediately on server startup instead of waiting 24 hours 44 | * Optimize loading intermediate certificate change and reduce unnecessary warnings about invalid certs 45 | 46 | Fixes in patch 1.1.2: 47 | * Raise validation error if attempting to use wildcards 48 | 49 | 50 | * Fix bug in detecting Kestrel in .NET 5 51 | 52 | 53 | First release! This is basically the same as McMaster.AspNetCore.LetsEncrypt 0.5.0, but has been renamed. 54 | 55 | See https://github.com/natemcmaster/LettuceEncrypt/#Usage for instructions. 56 | 57 | Changes since 0.5.0: 58 | * Renamed namespaces, classes, and methods to 'LettuceEncrypt' 59 | * Fix an error when saving certs to disk when running as a Linux system account with systemd 60 | * Update dependencies versions to latest 61 | * Fix invalid and duplicate warnings invalid certificate chains 62 | 63 | 64 | $(PackageReleaseNotes.Trim()) 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/StrongName.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natemcmaster/LettuceEncrypt/424f324c64cfbfb8f658857de791607673b05391/src/StrongName.snk -------------------------------------------------------------------------------- /src/common.psm1: -------------------------------------------------------------------------------- 1 | function exec([string]$_cmd) { 2 | write-host -ForegroundColor DarkGray ">>> $_cmd $args" 3 | $ErrorActionPreference = 'Continue' 4 | & $_cmd @args 5 | $ErrorActionPreference = 'Stop' 6 | if ($LASTEXITCODE -ne 0) { 7 | write-error "Failed with exit code $LASTEXITCODE" 8 | exit 1 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natemcmaster/LettuceEncrypt/424f324c64cfbfb8f658857de791607673b05391/src/icon.png -------------------------------------------------------------------------------- /test/Integration/README.md: -------------------------------------------------------------------------------- 1 | Integration Testing 2 | =================== 3 | 4 | This project doesn't yet have automated tests for integration, so here is how to manually test. 5 | 6 | ## Testing in development using ngrok 7 | 8 | To test this project, I recommend the following steps. 9 | 10 | 1. Download and install [ngrok](https://ngrok.io), a command-line tool for creating a publically accessible tunnel to your computer. 11 | 12 | 2. Start `ngrok v2` by running `ngrok http -bind-tls=false 5000` or `ngrok v3` by `ngrok http --scheme=http 5000`. This will create a temporary, public URL `http://TMP.ngrok.io` 13 | 14 | ![ngrok](https://i.imgur.com/Vl8Egsv.png) 15 | 16 | 3. Edit your hosts file to redirect http://TMP.ngrok.io to localhost 17 | ``` 18 | sudo vim /etc/hosts 19 | ``` 20 | 21 | Add a new line for "127.0.0.1 TMP.ngrok.io" 22 | 23 | ![vim](https://i.imgur.com/BtJQSaL.png) 24 | 25 | 4. Set your app to use Let's Encrypt staging environment so you don't hit rate limits in generating certificates. 26 | 27 | ```csharp 28 | services.AddLettuceEncrypt(o => 29 | { 30 | o.DomainNames = new[] { "TMP.ngrok.io" }; 31 | o.UseStagingServer = true; // <--- use staging 32 | 33 | o.AcceptTermsOfService = true; 34 | o.EmailAddress = "admin@example.com"; 35 | }); 36 | ``` 37 | 38 | 5. `dotnet run` your application. 39 | 40 | ![run](https://i.imgur.com/eXqCKBL.png) 41 | 42 | And voila! The API should automatically provision and create an HTTPs certificate for TMP.ngrok.io. 43 | 44 | ## Testing Azure KeyVault 45 | 46 | In order to test KeyVault storage/retrieval, follow these steps: 47 | 48 | 1. Follow the ngrok steps above. 49 | 50 | 1. Create a key vault instance in Azure (see [docs](https://docs.microsoft.com/en-us/azure/key-vault/quick-create-portal) for details) 51 | 52 | 1. Add an account you have credentials for to the access policies for Certificates with the `Get` and `Import` permissions. 53 | 54 | 1. Update `ConfigureServices` method to set up Azure KeyVault access: 55 | 56 | ```csharp 57 | public void ConfigureServices(IServiceCollection services) 58 | { 59 | services.AddLettuceEncrypt() 60 | .AddAzureKeyVaultCertificateSource(o => 61 | { 62 | o.AzureKeyVaultEndpoint = "https://[url].vault.azure.net/"; 63 | }) 64 | .PersistCertificatesToAzureKeyVault(); 65 | } 66 | ``` 67 | 68 | 1. `dotnet run` your application. `Azure.Identity` will attempt to use default credentials to log into the configured KeyVault. If there are issues with using default credentials, consult the [documentation](https://azuresdkdocs.blob.core.windows.net/$web/dotnet/Azure.Identity/1.1.1/api/index.html) for details. This can be set with the following: 69 | 70 | ```csharp 71 | public void ConfigureServices(IServiceCollection services) 72 | { 73 | services.AddLettuceEncrypt() 74 | .AddAzureKeyVaultCertificateSource(o => 75 | { 76 | o.Credentials = new SomeCredentials(); 77 | o.AzureKeyVaultEndpoint = "https://[url].vault.azure.net/"; 78 | }) 79 | .PersistCertificatesToAzureKeyVault(); 80 | } 81 | ``` 82 | 83 | The certificate should now be persisted to KeyVault and will be retrieved at startup. 84 | 85 | ## Trusting test certs 86 | 87 | By default, certificates generated by Let's Encrypt's staging certificates will not appear as a trusted certificate. 88 | 89 | ![red-hazard](https://i.imgur.com/vnoqhCq.png) 90 | 91 | To trust a test certificate, on macOS 92 | 93 | 1. Open up "Keychain Access" and search for your certificate. 94 | 95 | ![keychain](https://i.imgur.com/w6BZvyN.png) 96 | 97 | 2. Right click on the certificate on click "Get Info" 98 | 99 | ![get-info](https://i.imgur.com/EL4fJAm.png) 100 | 101 | 3. Under the "Trust" section, change the drop-down to "Trust" and **close the info window**. This should prompt you for a password. 102 | 103 | ![trust-it](https://i.imgur.com/JmQnglg.png) 104 | 105 | 4. Refresh your browser. 106 | 107 | ![green](https://i.imgur.com/tyTaJwV.png) 108 | -------------------------------------------------------------------------------- /test/Integration/ngrok.yml: -------------------------------------------------------------------------------- 1 | tunnels: 2 | http: 3 | proto: http 4 | addr: 5000 5 | bind_tls: false 6 | -------------------------------------------------------------------------------- /test/Integration/run_ngrok: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ue 4 | 5 | DIR=$(cd $(dirname "${BASH_SOURCE[0]}") && pwd) 6 | 7 | home_config="" 8 | if [ -f "$HOME/.ngrok2/ngrok.yml" ]; then 9 | home_config="--config $HOME/.ngrok2/ngrok.yml" 10 | fi 11 | 12 | ngrok start --all --config $DIR/ngrok.yml $home_config 13 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.Azure.UnitTests/AzureKeyVaultTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Azure.Security.KeyVault.Certificates; 5 | using Azure.Security.KeyVault.Secrets; 6 | using LettuceEncrypt.Azure.Internal; 7 | using LettuceEncrypt.UnitTests; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Hosting.Internal; 11 | using Microsoft.Extensions.Logging.Abstractions; 12 | using Microsoft.Extensions.Options; 13 | using Moq; 14 | using Xunit; 15 | 16 | namespace LettuceEncrypt.Azure.UnitTests; 17 | 18 | public class AzureKeyVaultTests 19 | { 20 | private static void DefaultConfigure(AzureKeyVaultLettuceEncryptOptions options) 21 | { 22 | options.AzureKeyVaultEndpoint = "http://something"; 23 | } 24 | 25 | [Fact] 26 | public void SourceAndRepositorySameInstance() 27 | { 28 | var provider = new ServiceCollection() 29 | .AddSingleton() 30 | .AddLogging() 31 | .AddLettuceEncrypt() 32 | .PersistCertificatesToAzureKeyVault(DefaultConfigure) 33 | .Services 34 | .BuildServiceProvider(validateScopes: true); 35 | 36 | 37 | var repository = provider.GetServices().OfType() 38 | .First(); 39 | var source = provider.GetServices().OfType() 40 | .First(); 41 | 42 | Assert.Same(source, repository); 43 | } 44 | 45 | [Fact] 46 | public void MultipleCallsToPersistCertificatesToAzureKeyVaultDoesNotDuplicateServices() 47 | { 48 | var provider = new ServiceCollection() 49 | .AddSingleton() 50 | .AddLogging() 51 | .AddLettuceEncrypt() 52 | .PersistCertificatesToAzureKeyVault(DefaultConfigure) 53 | .PersistCertificatesToAzureKeyVault(DefaultConfigure) 54 | .PersistCertificatesToAzureKeyVault(DefaultConfigure) 55 | .Services 56 | .BuildServiceProvider(validateScopes: true); 57 | 58 | 59 | Assert.Single(provider.GetServices().OfType()); 60 | Assert.Single(provider.GetServices().OfType()); 61 | } 62 | 63 | [Fact] 64 | public async Task ImportCertificateChecksDuplicate() 65 | { 66 | const string Domain1 = "github.com"; 67 | const string Domain2 = "azure.com"; 68 | 69 | var certClient = new Mock(); 70 | var certClientFactory = new Mock(); 71 | certClientFactory.Setup(c => c.Create()).Returns(certClient.Object); 72 | var options = Options.Create(new LettuceEncryptOptions()); 73 | 74 | options.Value.DomainNames = new[] { Domain1, Domain2 }; 75 | 76 | var repository = new AzureKeyVaultCertificateRepository( 77 | certClientFactory.Object, 78 | Mock.Of(), 79 | options, 80 | NullLogger.Instance); 81 | foreach (var domain in options.Value.DomainNames) 82 | { 83 | var certificateToSave = TestUtils.CreateTestCert(domain); 84 | await repository.SaveAsync(certificateToSave, CancellationToken.None); 85 | } 86 | 87 | certClient.Verify(t => t.GetCertificateAsync(AzureKeyVaultCertificateRepository.NormalizeHostName(Domain1), 88 | CancellationToken.None)); 89 | certClient.Verify(t => t.GetCertificateAsync(AzureKeyVaultCertificateRepository.NormalizeHostName(Domain2), 90 | CancellationToken.None)); 91 | } 92 | 93 | [Fact] 94 | public async Task GetCertificateLooksForDomainsAsync() 95 | { 96 | const string Domain1 = "github.com"; 97 | const string Domain2 = "azure.com"; 98 | 99 | var secretClient = new Mock(); 100 | var secretClientFactory = new Mock(); 101 | secretClientFactory.Setup(c => c.Create()).Returns(secretClient.Object); 102 | var options = Options.Create(new LettuceEncryptOptions()); 103 | 104 | options.Value.DomainNames = new[] { Domain1, Domain2 }; 105 | 106 | var repository = new AzureKeyVaultCertificateRepository( 107 | Mock.Of(), 108 | secretClientFactory.Object, options, 109 | NullLogger.Instance); 110 | 111 | var certificates = await repository.GetCertificatesAsync(CancellationToken.None); 112 | 113 | Assert.Empty(certificates); 114 | 115 | secretClient.Verify(t => t.GetSecretAsync(AzureKeyVaultCertificateRepository.NormalizeHostName(Domain1), 116 | null, CancellationToken.None)); 117 | secretClient.Verify(t => t.GetSecretAsync(AzureKeyVaultCertificateRepository.NormalizeHostName(Domain2), 118 | null, CancellationToken.None)); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.Azure.UnitTests/ConfigurationBindingTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | using Xunit; 8 | 9 | namespace LettuceEncrypt.Azure.UnitTests; 10 | 11 | public class ConfigurationBindingTests 12 | { 13 | [Fact] 14 | public void ItBindsToConfig() 15 | { 16 | var vaultUrl = "https://my.vault.azure.net/"; 17 | var mySecretName = "my-secret-name"; 18 | var data = new Dictionary 19 | { 20 | ["LettuceEncrypt:AzureKeyVault:AzureKeyVaultEndpoint"] = vaultUrl, 21 | ["LettuceEncrypt:AzureKeyVault:AccountKeySecretName"] = mySecretName, 22 | }; 23 | var config = new ConfigurationBuilder() 24 | .AddInMemoryCollection(data) 25 | .Build(); 26 | 27 | var services = new ServiceCollection() 28 | .AddSingleton(config) 29 | .AddLettuceEncrypt() 30 | .PersistCertificatesToAzureKeyVault() 31 | .Services 32 | .BuildServiceProvider(true); 33 | 34 | var options = services.GetRequiredService>(); 35 | 36 | Assert.Equal(vaultUrl, options.Value.AzureKeyVaultEndpoint); 37 | Assert.Equal(mySecretName, options.Value.AccountKeySecretName); 38 | } 39 | 40 | [Fact] 41 | public void ExplicitOptionsWin() 42 | { 43 | var data = new Dictionary 44 | { 45 | ["LettuceEncrypt:AzureKeyVault:AzureKeyVaultEndpoint"] = "https://fromconfig/", 46 | }; 47 | var config = new ConfigurationBuilder() 48 | .AddInMemoryCollection(data) 49 | .Build(); 50 | 51 | var services = new ServiceCollection() 52 | .AddSingleton(config) 53 | .AddLettuceEncrypt(o => { o.EmailAddress = "code"; }) 54 | .PersistCertificatesToAzureKeyVault(o => o.AzureKeyVaultEndpoint = "https://incode/") 55 | .Services 56 | .BuildServiceProvider(true); 57 | 58 | var options = services.GetRequiredService>(); 59 | 60 | Assert.Equal("https://incode/", options.Value.AzureKeyVaultEndpoint); 61 | } 62 | 63 | [Theory] 64 | [InlineData(null)] 65 | [InlineData("")] 66 | [InlineData("not a url")] 67 | public void ItValidatesEndpointIsUrl(string invalidEndpoint) 68 | { 69 | var services = new ServiceCollection() 70 | .AddLettuceEncrypt() 71 | .PersistCertificatesToAzureKeyVault(o => { o.AzureKeyVaultEndpoint = invalidEndpoint; }) 72 | .Services 73 | .BuildServiceProvider(true); 74 | 75 | var options = services.GetRequiredService>(); 76 | 77 | var ex = Assert.Throws(() => options.Value); 78 | Assert.Contains(nameof(AzureKeyVaultLettuceEncryptOptions.AzureKeyVaultEndpoint), ex.Message); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.Azure.UnitTests/LettuceEncrypt.Azure.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/AcmeCertificateFactoryTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Collections.Immutable; 5 | using System.Text; 6 | using Certes; 7 | using Certes.Acme; 8 | using LettuceEncrypt.Acme; 9 | using LettuceEncrypt.Internal; 10 | using LettuceEncrypt.Internal.PfxBuilder; 11 | using Microsoft.Extensions.Hosting.Internal; 12 | using Microsoft.Extensions.Logging.Abstractions; 13 | using Microsoft.Extensions.Options; 14 | using Xunit; 15 | 16 | namespace LettuceEncrypt.UnitTests; 17 | 18 | public sealed class AcmeCertificateFactoryTest 19 | { 20 | private static readonly byte[] TestBytes1 = { 0x01, 0x01, 0x01, 0x01, 0x01 }; 21 | private static readonly byte[] TestBytes2 = { 0x02, 0x02, 0x02 }; 22 | private static readonly byte[] TestBytes3 = { 0x03 }; 23 | private static readonly byte[] TestBytes4 = { 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 }; 24 | 25 | [Theory] 26 | [MemberData(nameof(AdditionalIssuerTestData))] 27 | public void PassedConfiguredAdditionalValidIssuers( 28 | LettuceEncryptOptions options, 29 | ICertificateAuthorityConfiguration certificateAuthority, 30 | IReadOnlyCollection expectedAdditionalIssuers) 31 | { 32 | var pfxBuilderStub = new PfxBuilderStub(); 33 | var acmeCertificateFactory = CreateAcmeCertificateFactory(options, certificateAuthority, pfxBuilderStub); 34 | 35 | // This test verifies if the configured issuers are passed to the builder. 36 | // The rest of the data does not impact the test and is therefore nulled or stubbed out. 37 | _ = acmeCertificateFactory.CreatePfxBuilder(certificateChain: null!, certKey: null!); 38 | 39 | Assert.Equal(expectedAdditionalIssuers, pfxBuilderStub.Issuers); 40 | } 41 | 42 | public static TheoryData> AdditionalIssuerTestData() 43 | => new() 44 | { 45 | { 46 | new LettuceEncryptOptions() { AdditionalIssuers = new[] { Encoding.UTF8.GetString(TestBytes1), Encoding.UTF8.GetString(TestBytes2), Encoding.UTF8.GetString(TestBytes3) } }, 47 | new DefaultCertificateAuthorityConfiguration(new HostingEnvironment(), Options.Create(new LettuceEncryptOptions())), 48 | ImmutableArray.Create(TestBytes1, TestBytes2, TestBytes3) 49 | }, 50 | { 51 | new LettuceEncryptOptions(), 52 | new StubCertificateAuthorityConfiguration(new[] { Encoding.UTF8.GetString(TestBytes1), Encoding.UTF8.GetString(TestBytes2), Encoding.UTF8.GetString(TestBytes3) }), 53 | ImmutableArray.Create(TestBytes1, TestBytes2, TestBytes3) 54 | }, 55 | { 56 | new LettuceEncryptOptions { AdditionalIssuers = new[] { Encoding.UTF8.GetString(TestBytes1), Encoding.UTF8.GetString(TestBytes3) } }, 57 | new StubCertificateAuthorityConfiguration(new[] { Encoding.UTF8.GetString(TestBytes2), Encoding.UTF8.GetString(TestBytes4) }), 58 | ImmutableArray.Create(TestBytes1, TestBytes3, TestBytes2, TestBytes4) 59 | }, 60 | }; 61 | 62 | private static AcmeCertificateFactory CreateAcmeCertificateFactory( 63 | LettuceEncryptOptions options, 64 | ICertificateAuthorityConfiguration certificateAuthority, 65 | IPfxBuilder pfxBuilder) 66 | { 67 | return new AcmeCertificateFactory( 68 | acmeClientFactory: null!, 69 | tosChecker: null!, 70 | options: Options.Create(options), 71 | challengeStore: null!, 72 | logger: NullLogger.Instance, 73 | appLifetime: new ApplicationLifetime(NullLogger.Instance), 74 | tlsAlpnChallengeResponder: null!, 75 | dnsChallengeProvider: new NoOpDnsChallengeProvider(), 76 | certificateAuthority: certificateAuthority, 77 | pfxBuilderFactory: new PfxBuilderFactoryStub(pfxBuilder)); 78 | } 79 | 80 | private sealed class PfxBuilderFactoryStub : IPfxBuilderFactory 81 | { 82 | private readonly IPfxBuilder _stub; 83 | 84 | public PfxBuilderFactoryStub(IPfxBuilder stub) 85 | { 86 | _stub = stub; 87 | } 88 | 89 | public IPfxBuilder FromChain(CertificateChain certificateChain, IKey certKey) 90 | => _stub; 91 | } 92 | 93 | private sealed class PfxBuilderStub : IPfxBuilder 94 | { 95 | public IList Issuers { get; } = new List(); 96 | 97 | public void AddIssuer(byte[] certificate) 98 | => Issuers.Add(certificate); 99 | 100 | public byte[] Build(string friendlyName, string password) 101 | => throw new NotSupportedException(); 102 | } 103 | 104 | private sealed class StubCertificateAuthorityConfiguration : ICertificateAuthorityConfiguration 105 | { 106 | public StubCertificateAuthorityConfiguration(string[] issuerCertificates) 107 | { 108 | IssuerCertificates = issuerCertificates; 109 | } 110 | 111 | public Uri AcmeDirectoryUri => WellKnownServers.LetsEncryptStagingV2; 112 | 113 | public string[] IssuerCertificates { get; } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/CertificateSelectorTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal; 5 | using Microsoft.AspNetCore.Connections; 6 | using Microsoft.Extensions.Logging.Abstractions; 7 | using Microsoft.Extensions.Options; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace LettuceEncrypt.UnitTests; 12 | 13 | using static TestUtils; 14 | 15 | public class CertificateSelectorTests 16 | { 17 | [Fact] 18 | public void ItUsesCertCommonName() 19 | { 20 | const string CommonName = "selector.test.natemcmaster.com"; 21 | 22 | var testCert = CreateTestCert(CommonName); 23 | var selector = new CertificateSelector( 24 | Options.Create(new LettuceEncryptOptions()), 25 | NullLogger.Instance); 26 | 27 | selector.Add(testCert); 28 | 29 | var domain = Assert.Single(selector.SupportedDomains); 30 | Assert.Equal(CommonName, domain); 31 | } 32 | 33 | [Fact] 34 | public void ItUsesSubjectAlternativeName() 35 | { 36 | var domainNames = new[] 37 | { 38 | "san1.test.natemcmaster.com", 39 | "san2.test.natemcmaster.com", 40 | "san3.test.natemcmaster.com", 41 | }; 42 | var testCert = CreateTestCert(domainNames); 43 | var selector = new CertificateSelector( 44 | Options.Create(new LettuceEncryptOptions()), 45 | NullLogger.Instance); 46 | 47 | selector.Add(testCert); 48 | 49 | 50 | Assert.Equal( 51 | new HashSet(domainNames), 52 | new HashSet(selector.SupportedDomains)); 53 | } 54 | 55 | [Fact] 56 | public void ItSelectsCertificateWithLongestTTL() 57 | { 58 | const string CommonName = "test.natemcmaster.com"; 59 | var fiveDays = CreateTestCert(CommonName, DateTimeOffset.Now.AddDays(5)); 60 | var tenDays = CreateTestCert(CommonName, DateTimeOffset.Now.AddDays(10)); 61 | 62 | var selector = new CertificateSelector( 63 | Options.Create(new LettuceEncryptOptions()), 64 | NullLogger.Instance); 65 | 66 | selector.Add(fiveDays); 67 | selector.Add(tenDays); 68 | 69 | Assert.Same(tenDays, selector.Select(Mock.Of(), CommonName)); 70 | 71 | selector.Reset(CommonName); 72 | 73 | Assert.Null(selector.Select(Mock.Of(), CommonName)); 74 | 75 | selector.Add(tenDays); 76 | selector.Add(fiveDays); 77 | 78 | Assert.Same(tenDays, selector.Select(Mock.Of(), CommonName)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/ChallengeTypeTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Acme; 5 | using Xunit; 6 | 7 | namespace LettuceEncrypt.UnitTests; 8 | 9 | public class ChallengeTypeTests 10 | { 11 | [Fact] 12 | public void AnyIsAlwaysTrue() 13 | { 14 | Assert.True(ChallengeType.Any.HasFlag(ChallengeType.Http01)); 15 | Assert.True(ChallengeType.Any.HasFlag(ChallengeType.TlsAlpn01)); 16 | Assert.True(ChallengeType.Any.HasFlag(ChallengeType.Any)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/ConfigurationBindingTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | using Xunit; 8 | 9 | namespace LettuceEncrypt.Tests; 10 | 11 | public class ConfigurationBindingTests 12 | { 13 | [Fact] 14 | public void ItBindsToConfig() 15 | { 16 | var options = ParseOptions(new() 17 | { 18 | ["LettuceEncrypt:AcceptTermsOfService"] = "true", 19 | ["LettuceEncrypt:DomainNames:0"] = "one.com", 20 | ["LettuceEncrypt:DomainNames:1"] = "two.com", 21 | ["LettuceEncrypt:AllowedChallengeTypes"] = "Http01", 22 | }); 23 | 24 | Assert.True(options.AcceptTermsOfService); 25 | Assert.Collection(options.DomainNames, 26 | one => Assert.Equal("one.com", one), 27 | two => Assert.Equal("two.com", two)); 28 | Assert.Equal(Acme.ChallengeType.Http01, options.AllowedChallengeTypes); 29 | } 30 | 31 | [Fact] 32 | public void ExplicitOptionsWin() 33 | { 34 | var data = new Dictionary 35 | { 36 | ["LettuceEncrypt:EmailAddress"] = "config", 37 | }; 38 | var config = new ConfigurationBuilder() 39 | .AddInMemoryCollection(data) 40 | .Build(); 41 | 42 | var services = new ServiceCollection() 43 | .AddSingleton(config) 44 | .AddLettuceEncrypt(o => { o.EmailAddress = "code"; }) 45 | .Services 46 | .BuildServiceProvider(true); 47 | 48 | var options = services.GetRequiredService>(); 49 | 50 | Assert.Equal("code", options.Value.EmailAddress); 51 | } 52 | 53 | [Theory] 54 | [InlineData("http01", Acme.ChallengeType.Http01)] 55 | [InlineData("HTTP01", Acme.ChallengeType.Http01)] 56 | [InlineData("Any", Acme.ChallengeType.Any)] 57 | [InlineData("TlsAlpn01, http01", Acme.ChallengeType.TlsAlpn01 | Acme.ChallengeType.Http01)] 58 | public void ItParsesEnumValuesForChallengeType(string value, Acme.ChallengeType challengeType) 59 | { 60 | var options = ParseOptions(new() 61 | { 62 | ["LettuceEncrypt:AllowedChallengeTypes"] = value, 63 | }); 64 | 65 | Assert.Equal(challengeType, options.AllowedChallengeTypes); 66 | } 67 | 68 | [Fact] 69 | public void DoesNotSupportWildcardDomains() 70 | { 71 | Assert.Throws(() => 72 | ParseOptions(new() 73 | { 74 | ["LettuceEncrypt:DomainNames:0"] = "*.natemcmaster.com", 75 | })); 76 | } 77 | 78 | [Fact] 79 | public void CanSetAdditionalIssuers() 80 | { 81 | var options = ParseOptions(new() 82 | { 83 | ["LettuceEncrypt:AdditionalIssuers:0"] = "-----BEGIN CERTIFICATE-----surely-a-certificate-----END CERTIFICATE-----", 84 | ["LettuceEncrypt:AdditionalIssuers:1"] = "-----BEGIN CERTIFICATE-----surely-another-certificate-----END CERTIFICATE-----", 85 | }); 86 | 87 | Assert.Collection(options.AdditionalIssuers, 88 | one => Assert.Equal("-----BEGIN CERTIFICATE-----surely-a-certificate-----END CERTIFICATE-----", one), 89 | two => Assert.Equal("-----BEGIN CERTIFICATE-----surely-another-certificate-----END CERTIFICATE-----", two)); 90 | } 91 | 92 | private static LettuceEncryptOptions ParseOptions(Dictionary input) 93 | { 94 | var config = new ConfigurationBuilder() 95 | .AddInMemoryCollection(input) 96 | .Build(); 97 | 98 | var services = new ServiceCollection() 99 | .AddSingleton(config) 100 | .AddLettuceEncrypt() 101 | .Services 102 | .BuildServiceProvider(true); 103 | 104 | var options = services.GetRequiredService>(); 105 | return options.Value; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/DefaultCertificateAuthorityConfigurationTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using Certes.Acme; 5 | using LettuceEncrypt.Internal; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Hosting.Internal; 8 | using Microsoft.Extensions.Options; 9 | using Xunit; 10 | 11 | namespace LettuceEncrypt.UnitTests; 12 | 13 | public class DefaultCertificateAuthorityConfigurationTests 14 | { 15 | public static TheoryData EnvironmentToDefaultAcmeServer() 16 | { 17 | return new TheoryData 18 | { 19 | {Environments.Development, WellKnownServers.LetsEncryptStagingV2}, 20 | {Environments.Staging, WellKnownServers.LetsEncryptV2}, 21 | {Environments.Production, WellKnownServers.LetsEncryptV2}, 22 | {null, WellKnownServers.LetsEncryptV2}, 23 | }; 24 | } 25 | 26 | [Theory] 27 | [MemberData(nameof(EnvironmentToDefaultAcmeServer))] 28 | public void UsesDefaultAcmeServerBasedOnEnvironmentName(string environmentName, Uri acmeServer) 29 | { 30 | var env = new HostingEnvironment 31 | { 32 | EnvironmentName = environmentName 33 | }; 34 | var provider = new DefaultCertificateAuthorityConfiguration( 35 | env, 36 | Options.Create(new LettuceEncryptOptions())); 37 | 38 | Assert.Equal( 39 | acmeServer, 40 | provider.AcmeDirectoryUri); 41 | } 42 | 43 | 44 | [Theory] 45 | [InlineData("Development")] 46 | [InlineData("Production")] 47 | public void OverridesDefaultAcmeServer(string environmentName) 48 | { 49 | var env = new HostingEnvironment 50 | { 51 | EnvironmentName = environmentName 52 | }; 53 | 54 | var useStaging = Options.Create(new LettuceEncryptOptions 55 | { 56 | UseStagingServer = true, 57 | }); 58 | var provider = new DefaultCertificateAuthorityConfiguration(env, useStaging); 59 | 60 | Assert.Equal( 61 | WellKnownServers.LetsEncryptStagingV2, 62 | provider.AcmeDirectoryUri); 63 | 64 | var useProduction = Options.Create(new LettuceEncryptOptions 65 | { 66 | UseStagingServer = false, 67 | }); 68 | 69 | provider = new DefaultCertificateAuthorityConfiguration(env, useProduction); 70 | 71 | Assert.Equal( 72 | WellKnownServers.LetsEncryptV2, 73 | provider.AcmeDirectoryUri); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/DeveloperCertLoaderTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using LettuceEncrypt.Internal; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging.Abstractions; 8 | using Moq; 9 | using Xunit; 10 | 11 | 12 | namespace LettuceEncrypt.UnitTests; 13 | 14 | public class DeveloperCertLoaderTests 15 | { 16 | [Fact] 17 | public async Task ItFindsDevCert() 18 | { 19 | var env = new Mock(); 20 | env.SetupGet(e => e.EnvironmentName).Returns("Development"); 21 | 22 | var loader = new DeveloperCertLoader(env.Object, NullLogger.Instance); 23 | 24 | var certs = await loader.GetCertificatesAsync(default); 25 | Assert.NotEmpty(certs); 26 | Assert.All(certs, c => { Assert.Equal("localhost", c.GetNameInfo(X509NameType.SimpleName, false)); }); 27 | } 28 | 29 | [Fact] 30 | public async Task ItDoesNotLoadCertUnlessDevEnvironment() 31 | { 32 | var env = new Mock(); 33 | env.SetupGet(e => e.EnvironmentName).Returns("Staging"); 34 | 35 | var loader = new DeveloperCertLoader(env.Object, NullLogger.Instance); 36 | 37 | var certs = await loader.GetCertificatesAsync(default); 38 | Assert.Empty(certs); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/FileSystemAccountStoreTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | #nullable enable 5 | using System.Text.Json; 6 | using Certes; 7 | using LettuceEncrypt.Accounts; 8 | using LettuceEncrypt.Internal; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Logging.Abstractions; 11 | using Microsoft.Extensions.Options; 12 | using Moq; 13 | using Xunit; 14 | 15 | namespace LettuceEncrypt.UnitTests; 16 | 17 | public class FileSystemAccountStoreTests : IDisposable 18 | { 19 | private readonly DirectoryInfo _testDir = 20 | new(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 21 | 22 | public void Dispose() 23 | { 24 | _testDir.Delete(recursive: true); 25 | } 26 | 27 | [Fact] 28 | public void ItWritesCreatesSubdir() 29 | { 30 | CreateStore(); 31 | 32 | Assert.True(Directory.Exists($"{_testDir}/accounts/acme-staging-v02.api.letsencrypt.org/directory/")); 33 | } 34 | 35 | [Fact] 36 | public async Task ItReturnsNullForNoAccountAsync() 37 | { 38 | var store = CreateStore(); 39 | 40 | Assert.Null(await store.GetAccountAsync(default)); 41 | } 42 | 43 | [Fact] 44 | public async Task ItStoresAsJson() 45 | { 46 | var store = CreateStore(); 47 | var key = KeyFactory.NewKey(Certes.KeyAlgorithm.RS256); 48 | var bytes = key.ToDer(); 49 | 50 | var account = new AccountModel 51 | { 52 | Id = 1, 53 | EmailAddresses = new[] { "test@test.com" }, 54 | PrivateKey = bytes, 55 | }; 56 | 57 | await store.SaveAccountAsync(account, default); 58 | 59 | var jsonFile = 60 | new FileInfo( 61 | Path.Combine(_testDir.FullName, "accounts/acme-staging-v02.api.letsencrypt.org/directory/1.json")); 62 | Assert.True(jsonFile.Exists); 63 | using var readStream = jsonFile.OpenRead(); 64 | var doc = await JsonDocument.ParseAsync(readStream); 65 | Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); 66 | } 67 | 68 | [Fact] 69 | public async Task ItParsesJson() 70 | { 71 | const string TestJson = @" 72 | { 73 | ""id"": 1, 74 | ""emailAddresses"": [ 75 | ""test@test.com"" 76 | ], 77 | ""privateKey"": ""MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCeM90BxLItcyEFJh/WrqYCpMtFLOuV0smrJMJKhHXnQNGR+MEH4LKvJf0SOLnUfKGSqHzoG16wakrJQpUXKEGHWIdCdtHOnwnP1A52JGyPipx7CU8dILN7EtHg/j2t5Z/XuG28ua0rz6WODBLQyV3/UuzXVAaEfWNL+49Az0sbJEetE2xVeKe22rZuMYeblLsoLGs9b4PBimkjmB5x0q2kJKX3OZh1i0qZwSOEb0+uIWYhoy+TZ7hcJJI3xKjbulHFoLdbUvrnTsf746R58j6HVpdfHK9D4AYJ27Cz+LAbVKXhR4aSahDr5f6NraLpaDq8KTPwfAl6kaUW41Z5iGOrAgMBAAECggEAER9B2zAjrKGaQElpBr4uP3kAewMqmDORGhHHaXM+o4GzbN4EXkrma+hrpG45RpMalZngsupLbEKEx5WKN1BnDzP4p6ved0NlN3YW/phgm4R//Rz70AY7BqX5yyUZHdoNW7adQeDCqkw1+dK6spgosTqTYZa5gdtkRNP8JCKLWWt+psWG27c6xJlxWaQtsdrmrj2lB09sgmEDJRjZcS+QK/5NKXfhFV01N9A3BdxFGZzJ3CHoEkvaINnlc8YnDBBG/G9WIeF0/p+KTvkppMxWk4QUrgKzcXgP1PsfHE4kUvN5LZcEzw/C5DNN4Fm7NycaApk8KdL/fTeHgGX+anqZqQKBgQDsydiJuzxQvomMsd1ykoEu5wZw7pXNWUrk4+nLIZ1B/99X2nsl7iGUa4t75X0gBj9E73IpsGlGS2j5B+fRZ+kcpWROl+AUk1lCgknLthtsKQxrgBVKW99NK7muACJTmvOYS96o0fK64mDQquMn0GniwhFwX1aHpIbUEJQMfP25FwKBgQCrCcUO82EsEBgRQyYWxEN+pIiksxjzvAjj/yYYVn0MzuDR0eyEUEED2Xx5GH45Z6TK3fYdFiORntNoeYpgxj1NELRuNJygYQFKMYtjQEjlEbcNuy0Fi5baijiHUyyP7dps76sd2SHof+UJX5Mq59Mr/d+mS1bC5u6/RSC8tKVejQKBgQCxqwoU3i51j2H59YNpclAH90S3++ze9b7iW7iSuBgc63aTntWEMldz2/X+8sSeANH8UYXhjgKPwglzweDJGSSqX9cRuZdjGOSCqOviNDQDRhGRn7tZ3fGBH+vkiSk4fi2E+niJR27Plwh5yZ9DwneQs3kOThrJEEQyXnYXoLln5QKBgQCYDp96ozUIj2ZWMnRyWRoIRQ6WHgNY7RqaWAPuLzYNZP7Kiu7S0uZ6HahjoDrXniULljlvsnb8x0772tIDJzrogKloMK3uh082PsXE/ynPPOiY9IcaHveGYsvOw0siyjseDhT6/EcBBHMC2k1kH6XFvnZOyTvhGp22viZUneVHIQKBgFqJweAApLjG+NuLhJXt1VccGwfPg4ilJJhizzk8Orl2A37Nav2pbY+z1d6Asuj3RwgqZx3yb8OQLhrQrkQ5h9YBEfX3l8wNCOwnYH43/QonpHnpC9aUPiQp27veFl4v1zkXSL86Il8d0qGZ+boKkeUMirmb/4lwpyZ3ApXJhcHL"" 78 | }"; 79 | var jsonFile = Path.Combine(_testDir.FullName, 80 | "accounts/acme-staging-v02.api.letsencrypt.org/directory/1.json"); 81 | var path = Path.GetDirectoryName(jsonFile); 82 | Assert.NotNull(path); 83 | Directory.CreateDirectory(path!); 84 | await File.WriteAllTextAsync(jsonFile, TestJson); 85 | 86 | var store = CreateStore(); 87 | var account = await store.GetAccountAsync(default); 88 | 89 | Assert.NotNull(account); 90 | Assert.Equal(1, account!.Id); 91 | var email = Assert.Single(account.EmailAddresses); 92 | Assert.Equal("test@test.com", email); 93 | Assert.NotNull(account.Key); 94 | } 95 | 96 | private FileSystemAccountStore CreateStore() 97 | { 98 | var options = Options.Create(new LettuceEncryptOptions 99 | { 100 | UseStagingServer = true 101 | }); 102 | var mockCertificateAuthority = 103 | new DefaultCertificateAuthorityConfiguration(Mock.Of(), options); 104 | 105 | return new FileSystemAccountStore( 106 | _testDir, 107 | NullLogger.Instance, 108 | mockCertificateAuthority); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/FileSystemCertificateRepoTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | #nullable enable 5 | using System.Security.Cryptography.X509Certificates; 6 | using LettuceEncrypt.Internal; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Hosting.Internal; 10 | using Xunit; 11 | 12 | namespace LettuceEncrypt.UnitTests; 13 | 14 | using static TestUtils; 15 | 16 | public class FileSystemCertificateRepoTests 17 | { 18 | [Theory] 19 | [InlineData(null)] 20 | [InlineData("")] 21 | public async Task ItCanSaveCertsWithoutPassword(string? password) 22 | { 23 | var dir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 24 | var repo = new FileSystemCertificateRepository(dir, password); 25 | var cert = CreateTestCert("localhost"); 26 | var expectedFile = Path.Combine(dir.FullName, "certs", cert.Thumbprint + ".pfx"); 27 | await repo.SaveAsync(cert, default); 28 | 29 | Assert.NotNull(new X509Certificate2(expectedFile)); 30 | } 31 | 32 | [Fact] 33 | public async Task ItCreatesDirectory() 34 | { 35 | var dir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 36 | Assert.False(dir.Exists, "Directory should not exist yet created"); 37 | 38 | var repo = new FileSystemCertificateRepository(dir, "testpassword"); 39 | var cert = CreateTestCert("localhost"); 40 | var expectedFile = Path.Combine(dir.FullName, "certs", cert.Thumbprint + ".pfx"); 41 | 42 | await repo.SaveAsync(cert, default); 43 | 44 | dir.Refresh(); 45 | Assert.True(dir.Exists, "Directory was created"); 46 | Assert.True(File.Exists(expectedFile), "Cert exists"); 47 | } 48 | 49 | [Theory] 50 | [InlineData(null)] 51 | [InlineData("testpassword")] 52 | public async Task ItRoundTripsCert(string? password) 53 | { 54 | var dir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 55 | 56 | var repo = new FileSystemCertificateRepository(dir, password); 57 | var writeCert = CreateTestCert("localhost"); 58 | 59 | await repo.SaveAsync(writeCert, default); 60 | 61 | var certs = await repo.GetCertificatesAsync(default); 62 | var readCert = Assert.Single(certs); 63 | Assert.NotSame(writeCert, readCert); 64 | Assert.Equal(writeCert, readCert); 65 | } 66 | 67 | [Fact] 68 | public void DIConfiguresRepo() 69 | { 70 | var dir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 71 | var services = new ServiceCollection() 72 | .AddSingleton() 73 | .AddLogging() 74 | .AddLettuceEncrypt() 75 | .PersistDataToDirectory(dir, "testpassword") 76 | .Services 77 | .BuildServiceProvider(validateScopes: true); 78 | 79 | Assert.Single( 80 | services.GetServices() 81 | .OfType()); 82 | } 83 | 84 | [Fact] 85 | public void MultipleCallsToDIWithSameInfoDoesNotDuplicate() 86 | { 87 | var dir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 88 | 89 | var provider = new ServiceCollection() 90 | .AddSingleton() 91 | .AddLogging() 92 | .AddLettuceEncrypt() 93 | .PersistDataToDirectory(dir, "") 94 | .PersistDataToDirectory(dir, "") 95 | .Services 96 | .BuildServiceProvider(validateScopes: true); 97 | 98 | 99 | Assert.Single(provider.GetServices().OfType()); 100 | Assert.Single(provider.GetServices().OfType()); 101 | } 102 | 103 | [Fact] 104 | public void MultipleCallsToDIWithDirerentDirectory() 105 | { 106 | var dir1 = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 107 | var dir2 = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 108 | 109 | var provider = new ServiceCollection() 110 | .AddSingleton() 111 | .AddLogging() 112 | .AddLettuceEncrypt() 113 | .PersistDataToDirectory(dir1, "") 114 | .PersistDataToDirectory(dir2, "") 115 | .Services 116 | .BuildServiceProvider(validateScopes: true); 117 | 118 | 119 | Assert.Equal(2, 120 | provider.GetServices().OfType().Count()); 121 | Assert.Equal(2, 122 | provider.GetServices().OfType().Count()); 123 | } 124 | 125 | [Fact] 126 | public void ThrowsIfMultipleCallsDIWithDifferentPassword() 127 | { 128 | var dir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, Path.GetRandomFileName())); 129 | 130 | var provider = new ServiceCollection() 131 | .AddSingleton() 132 | .AddLogging() 133 | .AddLettuceEncrypt() 134 | .PersistDataToDirectory(dir, "one"); 135 | 136 | Assert.Throws(() => provider.PersistDataToDirectory(dir, "two")); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/HttpChallengeResponseMiddlewareTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.DependencyInjection.Extensions; 9 | using Moq; 10 | using Xunit; 11 | 12 | namespace LettuceEncrypt.UnitTests; 13 | 14 | public class HttpChallengeResponseMiddlewareTests 15 | { 16 | [Fact] 17 | public async Task ItRespondsWithTokenValue() 18 | { 19 | var services = new ServiceCollection() 20 | .AddLogging() 21 | .AddScoped() 22 | .AddLettuceEncrypt() 23 | .Services 24 | .BuildServiceProvider(validateScopes: true); 25 | 26 | var appBuilder = new ApplicationBuilder(services); 27 | appBuilder.UseHttpChallengeResponseMiddleware(); 28 | 29 | var app = appBuilder.Build(); 30 | 31 | var challengeStore = services.GetRequiredService(); 32 | const string TokenValue = "abcxyz123"; 33 | challengeStore.AddChallengeResponse("TOKEN-1", TokenValue); 34 | 35 | using var scope = services.CreateScope(); 36 | var context = new DefaultHttpContext 37 | { 38 | RequestServices = scope.ServiceProvider, 39 | Request = 40 | { 41 | Path = "/.well-known/acme-challenge/TOKEN-1", 42 | }, 43 | Response = 44 | { 45 | Body = new MemoryStream(), 46 | } 47 | }; 48 | 49 | await app.Invoke(context); 50 | 51 | context.Response.Body.Seek(0, SeekOrigin.Begin); 52 | var reader = new StreamReader(context.Response.Body); 53 | var streamText = await reader.ReadToEndAsync(); 54 | 55 | Assert.Equal(TokenValue, streamText); 56 | Assert.Equal("application/octet-stream", context.Response.ContentType); 57 | Assert.Equal(TokenValue.Length, context.Response.ContentLength); 58 | } 59 | 60 | [Fact] 61 | public async Task ItForwardsToNextMiddlewareForUnrecognizedChallenge() 62 | { 63 | var servicesCollection = new ServiceCollection() 64 | .AddLogging() 65 | .AddScoped() 66 | .AddLettuceEncrypt() 67 | .Services; 68 | 69 | var mockChallenge = new Mock(); 70 | mockChallenge 71 | .Setup(s => s.TryGetResponse("unknown", out It.Ref.IsAny)) 72 | .Returns(false) 73 | .Verifiable(); 74 | 75 | servicesCollection.Replace(ServiceDescriptor.Singleton(mockChallenge.Object)); 76 | 77 | var services = servicesCollection.BuildServiceProvider(validateScopes: true); 78 | 79 | var appBuilder = new ApplicationBuilder(services); 80 | appBuilder.UseHttpChallengeResponseMiddleware(); 81 | 82 | var app = appBuilder.Build(); 83 | 84 | using var scope = services.CreateScope(); 85 | var context = new DefaultHttpContext 86 | { 87 | RequestServices = scope.ServiceProvider, 88 | Request = 89 | { 90 | Path = "/.well-known/acme-challenge/unknown", 91 | }, 92 | }; 93 | 94 | await app.Invoke(context); 95 | 96 | Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); 97 | mockChallenge.VerifyAll(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/KestrelHttpsOptionsExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using McMaster.AspNetCore.Kestrel.Certificates; 6 | using Microsoft.AspNetCore.Connections; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Server.Kestrel.Https; 9 | using Moq; 10 | using Xunit; 11 | 12 | namespace LettuceEncrypt.UnitTests; 13 | 14 | using SelectorFunc = Func; 15 | 16 | public class KestrelHttpsOptionsExtensionsTests 17 | { 18 | [Fact] 19 | public void UseServerCertificateSelectorFallsbackToOriginalSelector() 20 | { 21 | var injectedSelector = new Mock(); 22 | injectedSelector 23 | .Setup(c => c.Select(It.IsAny(), It.IsAny())) 24 | .Returns(() => null); 25 | 26 | var originalSelectorWasCalled = false; 27 | SelectorFunc originalSelector = (_, __) => { originalSelectorWasCalled = true; return null; }; 28 | 29 | var options = new HttpsConnectionAdapterOptions 30 | { 31 | ServerCertificateSelector = originalSelector 32 | }; 33 | 34 | KestrelHttpsOptionsExtensions.UseServerCertificateSelector(options, injectedSelector.Object); 35 | options.ServerCertificateSelector(null, null); 36 | 37 | Assert.NotSame(options.ServerCertificateSelector, originalSelector); 38 | Assert.True(originalSelectorWasCalled); 39 | injectedSelector.VerifyAll(); 40 | } 41 | 42 | [Fact] 43 | public void UseServerCertificateSelectorDoesNotCallFallback() 44 | { 45 | var injectedSelector = new Mock(); 46 | injectedSelector 47 | .Setup(c => c.Select(It.IsAny(), It.IsAny())) 48 | .Returns(() => TestUtils.CreateTestCert("foo.test")); 49 | 50 | var originalSelectorWasCalled = false; 51 | SelectorFunc originalSelector = (_, __) => { originalSelectorWasCalled = true; return null; }; 52 | 53 | var options = new HttpsConnectionAdapterOptions 54 | { 55 | ServerCertificateSelector = originalSelector 56 | }; 57 | 58 | KestrelHttpsOptionsExtensions.UseServerCertificateSelector(options, injectedSelector.Object); 59 | options.ServerCertificateSelector(null, null); 60 | 61 | Assert.NotSame(options.ServerCertificateSelector, originalSelector); 62 | Assert.False(originalSelectorWasCalled); 63 | injectedSelector.VerifyAll(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/KestrelOptionsSetupTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Reflection; 5 | using Microsoft.AspNetCore.Server.Kestrel.Core; 6 | using Microsoft.AspNetCore.Server.Kestrel.Https; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Options; 9 | using Xunit; 10 | 11 | namespace LettuceEncrypt.UnitTests; 12 | 13 | public class KestrelOptionsSetupTests 14 | { 15 | [Fact] 16 | public void ItSetsCertificateSelector() 17 | { 18 | var services = new ServiceCollection() 19 | .AddLogging() 20 | .AddLettuceEncrypt() 21 | .Services 22 | .BuildServiceProvider(validateScopes: true); 23 | 24 | var kestrelOptions = services.GetRequiredService>().Value; 25 | // reflection is gross, but there is no public API for this so (shrug) 26 | var httpsDefaultsProp = 27 | typeof(KestrelServerOptions).GetProperty("HttpsDefaults", 28 | BindingFlags.Instance | BindingFlags.NonPublic); 29 | var httpsDefaultsFunc = 30 | (Action)httpsDefaultsProp.GetMethod.Invoke(kestrelOptions, 31 | Array.Empty()); 32 | var httpsDefaults = new HttpsConnectionAdapterOptions(); 33 | 34 | Assert.Null(httpsDefaults.ServerCertificateSelector); 35 | 36 | httpsDefaultsFunc(httpsDefaults); 37 | 38 | Assert.NotNull(httpsDefaults.ServerCertificateSelector); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/LettuceEncrypt.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0;net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/SkipOnWindowsCIBuildAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Runtime.InteropServices; 5 | using McMaster.Extensions.Xunit; 6 | 7 | namespace LettuceEncrypt.UnitTests; 8 | 9 | [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] 10 | internal class SkipOnWindowsCIBuildAttribute : Attribute, ITestCondition 11 | { 12 | public bool IsMet => string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) 13 | || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows); 14 | 15 | public string SkipReason { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/StartupCertificateLoaderTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using LettuceEncrypt.Internal; 6 | using Microsoft.Extensions.Logging.Abstractions; 7 | using Microsoft.Extensions.Options; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace LettuceEncrypt.UnitTests; 12 | 13 | public class StartupCertificateLoaderTests 14 | { 15 | [Fact] 16 | public async Task ItLoadsAllCertsIntoSelector() 17 | { 18 | var testCert = TestUtils.CreateTestCert("test1.natemcmaster.com"); 19 | IEnumerable certs = new[] { testCert }; 20 | 21 | var selector = new Mock( 22 | Options.Create(new LettuceEncryptOptions()), 23 | NullLogger.Instance); 24 | 25 | selector 26 | .Setup(s => s.Add(testCert)) 27 | .Verifiable(); 28 | 29 | var source1 = CreateCertSource(certs); 30 | var source2 = CreateCertSource(certs); 31 | 32 | var startupLoader = new StartupCertificateLoader( 33 | new[] { source1.Object, source2.Object }, 34 | selector.Object); 35 | 36 | await startupLoader.StartAsync(default); 37 | 38 | selector.VerifyAll(); 39 | source1.VerifyAll(); 40 | source2.VerifyAll(); 41 | } 42 | 43 | private Mock CreateCertSource(IEnumerable certs) 44 | { 45 | var source = new Mock(); 46 | source 47 | .Setup(s => s.GetCertificatesAsync(It.IsAny())) 48 | .Returns(Task.FromResult(certs)) 49 | .Verifiable(); 50 | return source; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/TermsOfServiceCheckerTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using LettuceEncrypt.Internal; 5 | using LettuceEncrypt.Internal.IO; 6 | using Microsoft.Extensions.Logging.Abstractions; 7 | using Microsoft.Extensions.Options; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace LettuceEncrypt.UnitTests; 12 | 13 | public class TermsOfServiceCheckerTests 14 | { 15 | private readonly Uri _tosUri = new("https://any"); 16 | 17 | [Fact] 18 | public void UnreadableConsoleAndUnsetInOptions() 19 | { 20 | var console = new Mock(); 21 | console.SetupGet(c => c.IsInputRedirected).Returns(true); 22 | var checker = new TermsOfServiceChecker( 23 | console.Object, 24 | Options.Create(new LettuceEncryptOptions()), 25 | NullLogger.Instance 26 | ); 27 | 28 | Assert.Throws(() 29 | => checker.EnsureTermsAreAccepted(_tosUri)); 30 | } 31 | 32 | [Theory] 33 | [InlineData("no")] 34 | [InlineData("N")] 35 | public void InvalidResponse(string response) 36 | { 37 | var console = new Mock(); 38 | console.SetupGet(c => c.IsInputRedirected).Returns(false); 39 | console.Setup(c => c.ReadLine()).Returns(response); 40 | var checker = new TermsOfServiceChecker( 41 | console.Object, 42 | Options.Create(new LettuceEncryptOptions()), 43 | NullLogger.Instance 44 | ); 45 | 46 | Assert.Throws(() 47 | => checker.EnsureTermsAreAccepted(_tosUri)); 48 | } 49 | 50 | 51 | [Theory] 52 | [InlineData("")] 53 | [InlineData("yes")] 54 | [InlineData("y")] 55 | [InlineData("Y")] 56 | public void YesOnCommandLine(string response) 57 | { 58 | var console = new Mock(); 59 | console.SetupGet(c => c.IsInputRedirected).Returns(false); 60 | console.Setup(c => c.ReadLine()).Returns(response); 61 | var checker = new TermsOfServiceChecker( 62 | console.Object, 63 | Options.Create(new LettuceEncryptOptions()), 64 | NullLogger.Instance 65 | ); 66 | 67 | checker.EnsureTermsAreAccepted(_tosUri); 68 | } 69 | 70 | [Fact] 71 | public void ConfiguredInOptions() 72 | { 73 | var checker = new TermsOfServiceChecker( 74 | Mock.Of(), 75 | Options.Create(new LettuceEncryptOptions 76 | { 77 | AcceptTermsOfService = true, 78 | }), 79 | NullLogger.Instance 80 | ); 81 | 82 | checker.EnsureTermsAreAccepted(_tosUri); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/TestUtils.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography; 5 | using System.Security.Cryptography.X509Certificates; 6 | 7 | namespace LettuceEncrypt.UnitTests; 8 | 9 | public class TestUtils 10 | { 11 | public static X509Certificate2 CreateTestCert(string commonName, DateTimeOffset? expires = null) 12 | { 13 | return CreateTestCert(new[] { commonName }, expires); 14 | } 15 | 16 | public static X509Certificate2 CreateTestCert(string[] domainNames, DateTimeOffset? expires = null) 17 | { 18 | expires ??= DateTimeOffset.Now.AddMinutes(10); 19 | var key = RSA.Create(2048); 20 | var csr = new CertificateRequest( 21 | "CN=" + domainNames[0], 22 | key, 23 | HashAlgorithmName.SHA512, 24 | RSASignaturePadding.Pkcs1); 25 | 26 | if (domainNames.Length > 1) 27 | { 28 | var sanBuilder = new SubjectAlternativeNameBuilder(); 29 | foreach (var san in domainNames.Skip(1)) 30 | { 31 | sanBuilder.AddDnsName(san); 32 | } 33 | 34 | csr.CertificateExtensions.Add(sanBuilder.Build()); 35 | } 36 | 37 | var cert = csr.CreateSelfSigned(DateTimeOffset.Now.AddMinutes(-1), expires.Value); 38 | int retries = 5; 39 | while (retries > 0) 40 | { 41 | try 42 | { 43 | // https://github.com/dotnet/runtime/issues/29144 44 | var certWithKey = cert.Export(X509ContentType.Pfx); 45 | return new X509Certificate2(certWithKey, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); 46 | } 47 | catch 48 | { 49 | retries--; 50 | if (retries > 0) 51 | { 52 | // For unclear reasons, on macOS it takes times for certs to be available for re-export. 53 | // Retries appear to work. 54 | Thread.Sleep(50); 55 | continue; 56 | } 57 | else 58 | { 59 | throw; 60 | } 61 | } 62 | } 63 | throw new Exception($"Could not create self signed cert for {domainNames}"); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/LettuceEncrypt.UnitTests/X509CertStoreTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Nate McMaster. 2 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using LettuceEncrypt.Internal; 6 | using McMaster.Extensions.Xunit; 7 | using Microsoft.Extensions.Logging.Abstractions; 8 | using Microsoft.Extensions.Options; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace LettuceEncrypt.UnitTests; 13 | 14 | using static TestUtils; 15 | 16 | public class X509CertStoreTests : IDisposable 17 | { 18 | private readonly ITestOutputHelper _output; 19 | private readonly LettuceEncryptOptions _options; 20 | private readonly X509CertStore _certStore; 21 | 22 | public X509CertStoreTests(ITestOutputHelper output) 23 | { 24 | _output = output; 25 | _options = new LettuceEncryptOptions(); 26 | _certStore = new X509CertStore(Options.Create(_options), NullLogger.Instance) 27 | { 28 | AllowInvalidCerts = true 29 | }; 30 | } 31 | 32 | public void Dispose() 33 | { 34 | _certStore.Dispose(); 35 | } 36 | 37 | [SkippableFact] 38 | [SkipOnWindowsCIBuild(SkipReason = 39 | "On Windows in CI, adding certs to store doesn't work for unclear reasons.")] 40 | public async Task ItFindsCertByCommonNameAsync() 41 | { 42 | var commonName = "x509store.read.test.natemcmaster.com"; 43 | _options.DomainNames = new[] { commonName }; 44 | using var x509Store = new X509Store(StoreName.My, StoreLocation.CurrentUser); 45 | x509Store.Open(OpenFlags.ReadWrite); 46 | var testCert = CreateTestCert(commonName); 47 | x509Store.Add(testCert); 48 | 49 | _output.WriteLine($"Adding cert {testCert.Thumbprint} to My/CurrentUser"); 50 | 51 | try 52 | { 53 | var certs = await _certStore.GetCertificatesAsync(default); 54 | var foundCert = Assert.Single(certs); 55 | Assert.NotNull(foundCert); 56 | Assert.Equal(testCert, foundCert); 57 | } 58 | finally 59 | { 60 | x509Store.Remove(testCert); 61 | } 62 | } 63 | 64 | [SkippableFact] 65 | [SkipOnWindowsCIBuild(SkipReason = 66 | "On Windows in CI, adding certs to store doesn't work for unclear reasons.")] 67 | public async Task ItSavesCertificates() 68 | { 69 | var commonName = "x509store.save.test.natemcmaster.com"; 70 | var testCert = CreateTestCert(commonName); 71 | using var x509store = new X509Store(StoreName.My, StoreLocation.CurrentUser); 72 | x509store.Open(OpenFlags.ReadWrite); 73 | 74 | try 75 | { 76 | await _certStore.SaveAsync(testCert, default); 77 | 78 | var certificates = x509store.Certificates.Find( 79 | X509FindType.FindByThumbprint, 80 | testCert.Thumbprint, 81 | validOnly: false); 82 | 83 | _output.WriteLine($"Searching for cert {testCert.Thumbprint} to My/CurrentUser"); 84 | 85 | var foundCert = Assert.Single(certificates); 86 | 87 | Assert.NotNull(foundCert); 88 | Assert.Equal(testCert, foundCert); 89 | } 90 | finally 91 | { 92 | x509store.Remove(testCert); 93 | } 94 | } 95 | 96 | [Fact] 97 | public async Task ItReturnsEmptyWhenCantFindCertAsync() 98 | { 99 | var commonName = "notfound.test.natemcmaster.com"; 100 | _options.DomainNames = new[] { commonName }; 101 | var certs = await _certStore.GetCertificatesAsync(default); 102 | Assert.Empty(certs); 103 | } 104 | } 105 | --------------------------------------------------------------------------------