diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index cb233ab5d..87a26ce65 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -42,6 +42,9 @@ jobs: dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.CdsHooks.Model/Udap.CdsHooks.Model.csproj dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Model/Udap.Tefca.Model.csproj + dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Server/Udap.Tefca.Server.csproj + + dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Ssraa.Server/Udap.Ssraa.Server.csproj - name: Push run: | @@ -61,3 +64,6 @@ jobs: dotnet nuget push ./Udap.CdsHooks.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} dotnet nuget push ./Udap.Tefca.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} + dotnet nuget push ./Udap.Tefca.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} + + dotnet nuget push ./Udap.Ssraa.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f90b9789c..d6a6c489e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,9 @@ jobs: dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.CdsHooks.Model/Udap.CdsHooks.Model.csproj dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Model/Udap.Tefca.Model.csproj + dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Server/Udap.Tefca.Server.csproj + + dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Ssraa.Server/Udap.Ssraa.Server.csproj - name: Push run: | @@ -61,6 +64,9 @@ jobs: dotnet nuget push ./Udap.CdsHooks.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} dotnet nuget push ./Udap.Tefca.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} - - + dotnet nuget push ./Udap.Tefca.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} + + dotnet nuget push ./Udap.Ssraa.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} + + diff --git a/.gitignore b/.gitignore index 2a09e440d..0be514ec9 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,4 @@ examples/clients/UdapEd/Client/wwwroot/temp/MudBlazor.min.css /examples/Udap.Proxy.Server/CertStore/issued/gfhirlabs.healthcare.client.pfx /coverage-results /.claude +docs/specifications/SSRAA/V2.0.0/ssraa_full-ig_v2.0.0.zip diff --git a/CLAUDE.md b/CLAUDE.md index 10ad3482c..a9505064a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ builder.Services.AddAuthentication().AddTieredOAuth(options => { ... }); - **Udap.Proxy.Server** - YARP-based reverse proxy to secure existing FHIR servers with UDAP - **Udap.Identity.Provider / Udap.Identity.Provider.2** - Tiered OAuth IdP examples - **Udap.CA** - Web UI for generating UDAP certificates +- **Sigil** (`examples/CA/`) - Modern PKI management tool (Blazor Server + FluentUI v4 + PostgreSQL). Three projects: `Sigil` (host), `Sigil.Common` (services/entities, no UI deps), `Sigil.UI` (Razor Class Library). See [`examples/CA/Sigil/docs/FEATURES.md`](examples/CA/Sigil/docs/FEATURES.md) for full feature list and [`examples/CA/ROADMAP.md`](examples/CA/ROADMAP.md) for planned phases. ### Test Projects (`/_tests`) @@ -134,3 +135,7 @@ builder.Services.AddAuthentication().AddTieredOAuth(options => { ... }); - **YARP 2.1.0** - Reverse proxy (for proxy examples) Package versions are centrally managed in `Directory.Packages.props`. + +## Specifications + +- **UDAP.org** is the base specification for this SDK. All 7 specs are in `docs/specifications/UDAP.org/` (see `README.md` there for index). SSRAA and TEFCA are profiles layered on top of UDAP.org. diff --git a/Directory.Packages.props b/Directory.Packages.props index c5000e7b5..04480987c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,9 @@ + + + @@ -43,19 +46,26 @@ + + + + + - - - - - - - - - - + + + + + + + + + + + + @@ -82,12 +92,12 @@ - - - - - - + + + + + + diff --git a/Dockerfile.gcp b/Dockerfile.gcp index 96d6f76bf..f804225d0 100644 --- a/Dockerfile.gcp +++ b/Dockerfile.gcp @@ -2,7 +2,7 @@ # docker build -t udap-gcp -f Dockerfile.gcp . # # Run interactively (first time — creates named volume for gcloud creds): -# docker run -it --name udap-gcp -v udap-gcloud-config:/root/.config/gcloud udap-gcp +# docker run -it --name udap-gcp -v udap-gcloud-config:/root/.config/gcloud -v C:\temp\_GCP:/output udap-gcp # # Re-enter after stopping: # docker start -ai udap-gcp @@ -52,6 +52,9 @@ COPY ["Udap.Smart.Model/Udap.Smart.Model.csproj", "Udap.Smart.Model/"] COPY ["Udap.Smart.Metadata/Udap.Smart.Metadata.csproj", "Udap.Smart.Metadata/"] COPY ["Udap.CdsHooks.Model/Udap.CdsHooks.Model.csproj", "Udap.CdsHooks.Model/"] COPY ["Udap.CdsHooks.Endpoint/Udap.CdsHooks.Endpoint.csproj", "Udap.CdsHooks.Endpoint/"] +COPY ["Udap.Tefca.Model/Udap.Tefca.Model.csproj", "Udap.Tefca.Model/"] +COPY ["Udap.Tefca.Server/Udap.Tefca.Server.csproj", "Udap.Tefca.Server/"] +COPY ["Udap.Ssraa.Server/Udap.Ssraa.Server.csproj", "Udap.Ssraa.Server/"] # -- Examples -- COPY ["examples/FhirLabsApi/FhirLabsApi.csproj", "examples/FhirLabsApi/"] COPY ["examples/Udap.Auth.Server/Udap.Auth.Server.csproj", "examples/Udap.Auth.Server/"] diff --git a/README.md b/README.md index db2e6769c..8c80588c8 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,246 @@ -# udap-dotnet +

+ UDAP Ecosystem Gears +

-UDAP SDK and reference implementation for .NET. +

udap-dotnet

-In short UDAP is a PKI extension profile to OAuth2. One or more PKIs can be hosted by a `Community`. Joining a `Community` results in a public/private key issued to a client. The client also chooses to explicitly trust one of the issuing certificates in that chain by installing in your client. In addition, all certificate chain validation including certificate revocation to a trusted root are performed. +

+ UDAP SDK and reference implementation for .NET +

-Note: This is a new project. It will take me some time to document. It should be very active in code changes and document additions. But feel free to try it out and add issues and/or pull requests. +

+ Build + NuGet + License + Stars +

-Many example apps are in the examples folder. +--- -- FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7. -- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. UDAP Draft Specifications are referenced and displayed in parts of this source code to document specification implementation. +> **Note:** Active development happens on the [`develop`](https://github.com/JoeShook/udap-dotnet/tree/develop) branch. The `main` branch contains the latest stable release. -## What does it support +--- -The repository contains components and example uses to support the following items from [Security for Scalable Registration, Authentication, and Authorization](http://hl7.org/fhir/us/udap-security/). The intent is to also support generic UDAP, but the driving force currently is supporting auto registration to FHIR® servers. +UDAP is a PKI extension profile to OAuth 2.0. One or more PKIs can be hosted by a **Community**. Joining a community results in a public/private key issued to a client. The client explicitly trusts one of the issuing certificates in that chain. Full certificate chain validation including certificate revocation to a trusted root is performed. -| Feature | Sub Feature | Supported | Comments | -|-------------------------|---|---------------------|--------------------------------------------------------| -| Udap.Client |||| -||UDAP Metadata Validation|✔️| Validates JWT and Certificates. See [Udap.Client](Udap.Client/docs/README.md) for usage. | -||Dynamic Client Registration|✔️| Functionally DCR exists but it has not been packaged and documented in Udap.Client package.| -||Access Token |✔️| Functionally exists and needs to be packaged and documented in Udap.Client packages | -||[hl7-b2b extension](http://hl7.org/fhir/us/udap-security/b2b.html#b2b-authorization-extension-object)|In process|This is hard coded in the UdapEd tool for illustration and to pass registration against Authorization Servers that require it. It is a required claim when requesting an access token in the client_credentials grant type flow profiles by UDAP Security under HL7 FHIR. I don't know if it stays here as a feature yet. I do want to call it out because it is a very meaningful feature of UDAP in the HL7 FHIR use case. | -| [Discovery](http://hl7.org/fhir/us/udap-security/discovery.html): UDAP Metadata for Resource Server|||| -| | Udap.Metadata.Server | ✔️ Including [Multi Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) | Certificate storage is a file strategy. User can implement their own ICertificateStore. May add a Entity Framework example and/or HSM in the future. Checkout the [2023 FHIR® DevDays Tutorial](udap-devdays-2023) to see it in action and the [Udap.Metadata.Server docs](https://github.com/JoeShook/Udap.Metadata.Vonk.Server/tree/develop) | -|| Udap.Metadata.Vonk.Server | Trial status. Including [Multi Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) | This is based on the same components that build ```Udap.Metadata.Server```. It can be used as a plugin for the Firely server. It has been tested on the Community edition. Readme more in the [docs](https://github.com/JoeShook/Udap.Metadata.Vonk.Server/tree/develop)| -| [Server Dynamic Registration](http://hl7.org/fhir/us/udap-security/registration.html)|| ✔️ Including [Multi Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities). | Highly Functional. The Deployed example FHIR® Server, "FhirLabsApi" is passing all udap.org Server Tests. I am going to revisit the Client Secrets persistence layer. Packages are dependent on Duende's Identity Server Nuget Packages.
  • ✔️ Registration with 201 created
  • ✔️ Registration with 200 updated
  • ✔️ Cancel registration with 200
  • ✔️ Cancel registration with 404 not found

Notes: Since this development, the Identity Server has Implemented Dynamic Registration. We could revisit this and try to enable UDAP under the new DCR feature. | -||Inclusion of Certifications and Endorsements|Started|Some example certification integration tests included from the client side | -Authorization and Authentication -| [Consumer-Facing](http://hl7.org/fhir/us/udap-security/consumer.html)|| ✔️ | Functionality same as B2B authorization_code flow. Client would typically register and or request user/* prefixed scopes | -| [Business-to-Business](http://hl7.org/fhir/us/udap-security/b2b.html)|| ✔️ | Works with client_credentials and authorization_code flows. | -||JWT Claim Extensions|Started|Some work completed for the B2B Authorization Extension (hl7-b2b) extension within integration tests. } -| [Tiered OAuth for User Authentication](http://hl7.org/fhir/us/udap-security/user.html) || ✔️ | Simply register Tiered OAuth functionality with the code snippet below.

There is a good integration test called [ClientAuthorize_IdPDiscovery_IdPRegistration_IdPAuthAccess_ClientAuthAccess_Test](/_tests/UdapServer.Tests/Conformance/Tiered/TieredOauthTests.cs). This spins up two in memory instances of Identity Server. One plays the role of Authorization Server and the other plays the role of Identity Provider. This test harness is important to quickly test Tiered OAuth without a user interface. When I built this first implementation, I was inspired by other implementations such as this great collection from the [aspnet-contrib](https://github.com/aspnet-contrib) organization, called [AspNet.Security.OpenId.Providers](https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers). There is another repository at this organization, called [AspNet.Security.OpenId.Providers](https://github.com/aspnet-contrib/AspNet.Security.OpenId.Providers). | +> - FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7. +> - UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. -```csharp -builder.Services.AddAuthentication() - .AddTieredOAuth(options => - { - options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; - }); -``` +--- -## PKI support -### Generate PKI for integration tests +## Specifications Supported -Part of this repository is a xUnit test project that will generate a couple PKI hierarchies for testing UDAP. The test is called `Udap.PKI.Generator`. I think showing the mechanics of what it takes to build out a PKI for UDAP will aid education and provide the flexibility to test interesting use cases. Run all the tests in the `Udap.PKI.Generator` project. The results include a folder with root a root certificate authority that issues intermediate certificates, certificate revocation lists, used certificates for community members and certs for web TLS certs. Each of the example web services located in the [examples](/examples) use MSBuild `Link`s to link to certificates appropriate to its PKI needs. So, if you would like to change something in the PKI just edit and run the tests. All examples will automatically pick up the changes. To enable crl lookup and AIA, Certification Authority Issuer resolution I just mapped crl, cert and anchor as static content via something like IIS on my Windows box. I may create a dotnet core app to make this easier and it into ci/cd better but this is where I am at so -I am not sure if this will stay in unit test form or not, but for now this is the technique. +| Specification | Status | Description | +|:---|:---:|:---| +| [UDAP.org](https://www.udap.org/) | ![Complete](https://img.shields.io/badge/status-complete-brightgreen) | Base UDAP specs — server metadata, DCR, JWT client auth, authorization grants, tiered OAuth, C&E, TLS client auth. All 7 specs in [`docs/specifications/UDAP.org/`](docs/specifications/UDAP.org/) | +| [HL7 FHIR UDAP Security IG (SSRAA)](http://hl7.org/fhir/us/udap-security/) | ![Complete](https://img.shields.io/badge/status-complete-brightgreen) | Security for Scalable Registration, Authentication, and Authorization | +| [TEFCA Facilitated FHIR](https://rce.sequoiaproject.org/) | ![In Progress](https://img.shields.io/badge/status-~80%25-yellow) | Trusted Exchange Framework and Common Agreement. B2B flows, XP code validation, IAS extensions. Specs in [`docs/specifications/TEFCA/`](docs/specifications/TEFCA/) | -### Certificate Authority tool +--- -A .NET UI and CLI tool to generate certificates for UDAP communities. A UI version of this tool is partially done in the [Udap.CA](/examples/Udap.CA/) project. The plan is to deploy this or install it yourself and allow quick generations of certificates and PKI hierarchies that can generate valid and various invalid certificates for testing to aid in experimenting with behaviors such as certificate revocation, expirations and other interesting potential certification use cases. +## Features at a Glance -## Components (Nuget packages) +
+

Discovery & Registration

-See the following Udap.Metadata.Server and Udap.Server sections. The Udap.Metadata.Server is for the resource server such a FHIR® Server. Udap.Server.Server is for the Identity Server. +| Feature | Status | +|:---|:---:| +| `.well-known/udap` metadata endpoint | :white_check_mark: | +| [Multiple Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) | :white_check_mark: | +| [Multi-Domain Metadata](./docs/multi-domain-metadata.md) — dynamic cert selection by request URL | :white_check_mark: | +| Metadata JWT & certificate chain validation | :white_check_mark: | +| [Dynamic Client Registration](http://hl7.org/fhir/us/udap-security/registration.html) (create / update / cancel) | :white_check_mark: | +| [Certifications & Endorsements](https://www.udap.org/udap-certifications-and-endorsements-stu1.html) | :white_check_mark: | -## Udap.Metadata.Server +
-Follow the [Udap.Metadata.Server docs](./Udap.Metadata.Server/docs/README.md) for configuring your resource server. +
+

Authorization & Authentication

-For hosting multiple domains with different certificates under a single community, see [Multi-Domain Metadata Support](./docs/multi-domain-metadata.md). +| Feature | Status | +|:---|:---:| +| [Consumer-Facing](http://hl7.org/fhir/us/udap-security/consumer.html) (authorization_code) | :white_check_mark: | +| [Business-to-Business](http://hl7.org/fhir/us/udap-security/b2b.html) (client_credentials) | :white_check_mark: | +| [Tiered OAuth](http://hl7.org/fhir/us/udap-security/user.html) — federated user authentication | :white_check_mark: | -## Udap.Client +
-Follow the [Udap.Client docs](./Udap.Client/docs/README.md) for configuring your UDAP client. +
+

Authorization Extension Objects (AEOs)

-## Udap.Server +| Extension | Spec | Status | Description | +|:---|:---|:---:|:---| +| `hl7-b2b` | SSRAA / TEFCA | :white_check_mark: | B2B extension for client_credentials — purpose_of_use enforcement | +| `hl7-b2b-user` | SSRAA | :white_check_mark: | B2B extension for authorization_code with user context (FHIR Person) | +| `tefca-ias` | TEFCA | :white_check_mark: | Individual Access Services — patient/user info, consent, id_token | +| `tefca_smart` | TEFCA | :black_square_button: | Constants defined; model and validation not yet implemented | -Follow the [Udap.Server docs](./Udap.Server/docs/README.md) for configuring your UDAP client. +**Pluggable validation** via `IUdapAuthorizationExtensionValidator` with per-community rules: +- **SSRAA** — validates against HL7 v3 PurposeOfUse value set (60+ codes) +- **TEFCA** — validates against 12 TEFCA Exchange Purpose (XP) codes, enforces single purpose_of_use, SAN URI matching -## Build and test +
-From root. +
+

TEFCA-Specific Features

-```csharp -dotnet restore -``` +| Feature | Status | +|:---|:---:| +| Exchange Purpose validation (all 12 XP codes) | :white_check_mark: | +| SAN URI exchange purpose matching | :white_check_mark: | +| Organization ID validation (RCE Directory format) | :white_check_mark: | +| TEFCA Authorization Error extension (consent_required) | :white_check_mark: | +| IAS flow via `tefca-ias` extension | :white_check_mark: | -If this is first build or you want to reset you certificates change to /_tests/Udap.PKI.Generator. This must be done once. Other projects are dependent on a lab environment with test PKIs. This is good in that the development experience will always have PKI structures that do not contain expired certificates unless that is an intended artifact of the data set. +
-```csharp -dotnet test +--- + +## NuGet Packages + +### Core + +| Package | Description | +|:---|:---| +| [![Udap.Model](https://img.shields.io/nuget/v/Udap.Model?label=Udap.Model)](https://www.nuget.org/packages/Udap.Model) | Data models and constants (zero external dependencies) | +| [![Udap.Common](https://img.shields.io/nuget/v/Udap.Common?label=Udap.Common)](https://www.nuget.org/packages/Udap.Common) | Certificate & trust chain validation, `ICertificateStore`, `ITrustAnchorStore` | +| [![Udap.Client](https://img.shields.io/nuget/v/Udap.Client?label=Udap.Client)](https://www.nuget.org/packages/Udap.Client) | Discovery, registration, token requests via `IUdapClient` | +| [![Udap.Metadata.Server](https://img.shields.io/nuget/v/Udap.Metadata.Server?label=Udap.Metadata.Server)](https://www.nuget.org/packages/Udap.Metadata.Server) | `.well-known/udap` endpoint for resource servers | +| [![Udap.Server](https://img.shields.io/nuget/v/Udap.Server?label=Udap.Server)](https://www.nuget.org/packages/Udap.Server) | Authorization server extensions (Duende IdentityServer), DCR | +| [![Udap.Server.Storage](https://img.shields.io/nuget/v/Udap.Server.Storage?label=Udap.Server.Storage)](https://www.nuget.org/packages/Udap.Server.Storage) | EF Core persistence (SQLite, SQL Server, PostgreSQL) | +| [![Udap.TieredOAuth](https://img.shields.io/nuget/v/Udap.TieredOAuth?label=Udap.TieredOAuth)](https://www.nuget.org/packages/Udap.TieredOAuth) | Federated OAuth / external IdP integration | + +### Profile-Specific + +| Package | Description | +|:---|:---| +| [![Udap.Ssraa.Server](https://img.shields.io/nuget/v/Udap.Ssraa.Server?label=Udap.Ssraa.Server)](https://www.nuget.org/packages/Udap.Ssraa.Server) | SSRAA community validation (purpose_of_use value set, required extensions) | +| [![Udap.Tefca.Model](https://img.shields.io/nuget/v/Udap.Tefca.Model?label=Udap.Tefca.Model)](https://www.nuget.org/packages/Udap.Tefca.Model) | TEFCA extension models (`tefca-ias`, `tefca_smart`, XP constants) | +| [![Udap.Tefca.Server](https://img.shields.io/nuget/v/Udap.Tefca.Server?label=Udap.Tefca.Server)](https://www.nuget.org/packages/Udap.Tefca.Server) | TEFCA community validation (XP codes, SAN matching) | + +### Configuration Docs + +- **Resource Server** — [Udap.Metadata.Server docs](./Udap.Metadata.Server/docs/README.md) | [Multi-domain metadata](./docs/multi-domain-metadata.md) +- **Client** — [Udap.Client docs](./Udap.Client/docs/README.md) +- **Authorization Server** — [Udap.Server docs](./Udap.Server/docs/README.md) + +--- + +## Examples + +See the [`examples/`](./examples) folder. Full list below. + +
+

Servers

+ +| Project | Description | +|:---|:---| +| [FhirLabsApi](./examples/FhirLabsApi/) | FHIR R4B resource server — passes all [udap.org](https://udap.org) conformance tests | +| [Udap.Auth.Server](./examples/Udap.Auth.Server/) | Authorization server with Duende IdentityServer + UDAP | +| [Udap.Proxy.Server](./examples/Udap.Proxy.Server/) | YARP reverse proxy — add UDAP security to existing FHIR servers | +| [Tefca.Proxy.Server](./examples/Tefca.Proxy.Server/) | TEFCA-configured reverse proxy | + +
+ +
+

Identity Providers

+ +| Project | Description | +|:---|:---| +| [Udap.Identity.Provider](./examples/Udap.Identity.Provider/) | Tiered OAuth IdP | +| [Udap.Identity.Provider.2](./examples/Udap.Identity.Provider.2/) | Second IdP for federation testing | + +
+ +
+

Admin & Tooling

+ +| Project | Description | +|:---|:---| +| [Udap.Auth.Server.Admin](./examples/Auth.Server.Admin/) | Admin UI for UDAP tables | +| [Udap.Pki.Cli](./examples/Udap.Pki.Cli/) | CLI tool for PKI operations | +| [UdapEd](https://github.com/JoeShook/UdapEd) | UDAP testing and exploration tool *(separate repository)* | + +
+ +
+

Sigil — PKI Management Tool

+ +[**Sigil**](./examples/CA/) is a modern certificate authority and PKI management tool built with .NET, Blazor Server, FluentUI v4, and PostgreSQL. + +:arrow_right: [Full feature list](./examples/CA/Sigil/docs/FEATURES.md) | [Roadmap](./examples/CA/ROADMAP.md) + +| Capability | Details | +|:---|:---| +| **Certificate Explorer** | Hierarchical tree view, color-coded status badges, chain validation, ASN.1 viewer | +| **Certificate Issuance** | Configurable templates (Root CA, Intermediate CA, UDAP Client, SSL Server), RSA & ECDSA | +| **Certificate Lifecycle** | Import (drag & drop, batch), renewal (re-key / re-sign), archive, revocation | +| **CRL Management** | Import, online resolution via CDP, revocation status tracking | +| **Remote Signing** | Pluggable `ISigningProvider` — HashiCorp Vault Transit and Google Cloud KMS | +| **Aspire Orchestration** | Dev / Docker / GCP launch profiles via `Sigil.AppHost` | + +
+ +--- + +## Getting Started + +### Build + +```bash +dotnet restore +dotnet test _tests/Udap.PKI.Generator # Generate test PKI (required once) +dotnet build Udap.sln ``` -Return to root +### Test -```csharp -dotnet build +```bash +dotnet test _tests/Udap.Common.Tests +dotnet test _tests/UdapMetadata.Tests +dotnet test _tests/UdapServer.Tests ``` -### Running tests +> **Tip:** Avoid `Udap.Client.System.Tests` in CI — those test against live servers. +> If SQLite DB sync issues occur, clean the `bin` folder in affected test projects. -Again it is probably best to avoid running Udap.PKI.Generator unless you need the certificates regenerated. I may migrate this away from unit test in future. Or create a src folder to isolate. -It is also best to avoid Udap.Client.System.Tests as they are for experimenting with live servers. Eventually the [FhirLabs UdapEd client tool](/examples/clients/UdapEd/Server/) will replace the need for this. +### Run Examples Locally -The following tests are normal to run and the build server runs these same tests. +```bash +# Install Tye (one-time) +dotnet tool install -g Microsoft.Tye --version "0.12.0-*" \ + --add-source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet6/nuget/v3/index.json -- [Udap.CA.Tests](./_tests/Udap.CA.Tests/) -- [Udap.Common.Tests](./_tests/Udap.Common.Tests/) -- [Udap.Support.Tests](./_tests/Udap.Support.Tests/) -- [UdapMetadata.Tests](./_tests/UdapMetadata.Tests/), tests two against the two example web services, FhirLabsApi and WeatherApi. -- [UdapServer.Tests](./_tests/UdapServer.Tests/). There are times when the bin folder should be deleted because the SQLite DB gets out of sync with the PKI artifacts because the Udap.PKI.Generator tests were ran after the SQLite database is created. +# Start all services with hot reload +tye run --watch +``` -## Other examples +### Quick Start — Tiered OAuth -### UDAP Admin UI Tool +```csharp +builder.Services.AddAuthentication() + .AddTieredOAuth(options => + { + options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; + }); +``` -This is a MVP version of an Admin UI Tool. It is only capable of administering the Udap prefixed tables. +--- -See [Udap.Auth.Server.Admin](./examples/Auth.Server.Admin/) +## Database Migrations -### UDAP CA UI Tool +| Project | Target | +|:---|:---| +| [UdapDb.SqlServer](./migrations/UdapDb.SqlServer/) | SQL Server | +| [UdapDb.Postgres](./migrations/UdapDb.Postgres/) | PostgreSQL | -This is barely implemented. The spirit of it is to create a easy to use CA for experimenting in a lab environment. At this point all the tooling for creating interesting PKI test data for success and failure use cases lives in the Udap.PKI.Generator test project. +## Key Dependencies -### UDAP Proxy Server +| Package | Purpose | +|:---|:---| +| [Duende.IdentityServer](https://duendesoftware.com/) | Identity & auth platform | +| [BouncyCastle.Cryptography](https://www.bouncycastle.org/csharp/) | X.509 PKI operations | +| [Hl7.Fhir.R4B](https://fire.ly/products/firely-net-sdk/) | FHIR models | +| [YARP](https://microsoft.github.io/reverse-proxy/) | Reverse proxy | -Turn your FHIR server into a UDAP secured FHIR server with [Udap.Proxy.Server](/examples/Udap.Proxy.Server). +Versions centrally managed in [`Directory.Packages.props`](./Directory.Packages.props). diff --git a/Udap.CdsHooks.Endpoint/docs/README.md b/Udap.CdsHooks.Endpoint/docs/README.md index 068845019..143c9c960 100644 --- a/Udap.CdsHooks.Endpoint/docs/README.md +++ b/Udap.CdsHooks.Endpoint/docs/README.md @@ -2,6 +2,6 @@ ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package - +## 📦 Nuget Package: [Udap.CdsHooks.Endpoint](https://www.nuget.org/packages/Udap.CdsHooks.Endpoint) +Server-side endpoint implementation for [CDS Hooks](https://cds-hooks.org/) services. Handles GET requests for service discovery and POST requests for hook invocations, processing CDS requests and returning recommendation cards. diff --git a/Udap.CdsHooks.Model/docs/README.md b/Udap.CdsHooks.Model/docs/README.md index f1869a73b..48f1014d0 100644 --- a/Udap.CdsHooks.Model/docs/README.md +++ b/Udap.CdsHooks.Model/docs/README.md @@ -2,6 +2,6 @@ ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package - +## 📦 Nuget Package: [Udap.CdsHooks.Model](https://www.nuget.org/packages/Udap.CdsHooks.Model) +Data models for the [CDS Hooks](https://cds-hooks.org/) specification. Includes `CdsServices`, `CdsCard`, `CdsRequest`, `CdsResponse`, `CdsAction`, and related types for implementing Clinical Decision Support services. diff --git a/Udap.Client/docs/README.md b/Udap.Client/docs/README.md index d71725fc0..c7c5ae084 100644 --- a/Udap.Client/docs/README.md +++ b/Udap.Client/docs/README.md @@ -172,12 +172,9 @@ services.AddScoped(sp => new TrustChainValidator(new X509Ch services.AddHttpClient(); ``` -TODO: Cover X509ChainPolicy - - ## Udap.Client Dynamic Client Registration with a ICertificateStore implementation ### Example projects -- [Udap.Config simple dependency injection](./examples/clients/1_UdapClientMetadata/README.md) -- [Udap.Client configuration with a ITrustAnchorStore implementation](./examples/clients/2_UdapClientMetadata/README.md) +- [Udap.Config simple dependency injection](../../examples/clients/1_UdapClientMetadata/README.md) +- [Udap.Client configuration with a ITrustAnchorStore implementation](../../examples/clients/2_UdapClientMetadata/README.md) diff --git a/Udap.Common/Certificates/ChainElementInfo.cs b/Udap.Common/Certificates/ChainElementInfo.cs index 2a903d27d..97879dc60 100644 --- a/Udap.Common/Certificates/ChainElementInfo.cs +++ b/Udap.Common/Certificates/ChainElementInfo.cs @@ -120,11 +120,12 @@ public class ChainValidationResult /// Whether the chain is valid and trusted. /// The chain elements with their validation problems. /// The community identifier from the matching trust anchor, if resolved. - public ChainValidationResult(bool isValid, IReadOnlyList chainElements, long? communityId = null) + public ChainValidationResult(bool isValid, IReadOnlyList chainElements, long? communityId = null, string? communityName = null) { IsValid = isValid; ChainElements = chainElements; CommunityId = communityId; + CommunityName = communityName; } /// Gets whether the certificate chain is valid and trusted. @@ -135,4 +136,7 @@ public ChainValidationResult(bool isValid, IReadOnlyList chain /// Gets the community identifier from the matching trust anchor, or null if not resolved. public long? CommunityId { get; } + + /// Gets the community name (URI) from the matching trust anchor, or null if not resolved. + public string? CommunityName { get; } } diff --git a/Udap.Common/Certificates/TrustChainValidator.cs b/Udap.Common/Certificates/TrustChainValidator.cs index 529c33eb2..6d0017a8a 100644 --- a/Udap.Common/Certificates/TrustChainValidator.cs +++ b/Udap.Common/Certificates/TrustChainValidator.cs @@ -231,6 +231,7 @@ public async Task IsTrustedCertificateAsync( var chainElements = new List(); bool foundAnchor = false; long? communityId = null; + string? communityName = null; bool chainValid = true; for (int i = 0; i < bcChain.Count; i++) @@ -282,6 +283,7 @@ public async Task IsTrustedCertificateAsync( if (matchingAnchor != null) { communityId = matchingAnchor.CommunityId; + communityName = matchingAnchor.Community; } } } @@ -326,7 +328,7 @@ public async Task IsTrustedCertificateAsync( NotifyUntrusted(certificate); } - return new ChainValidationResult(isValid, chainElements, communityId); + return new ChainValidationResult(isValid, chainElements, communityId, communityName); } catch (Exception ex) { diff --git a/Udap.Common/Metadata/UdapMetaDataBuilder.cs b/Udap.Common/Metadata/UdapMetaDataBuilder.cs index eb98c3d11..5172c9c8d 100644 --- a/Udap.Common/Metadata/UdapMetaDataBuilder.cs +++ b/Udap.Common/Metadata/UdapMetaDataBuilder.cs @@ -169,6 +169,16 @@ public async Task GetCommunitiesAsHtml(string path, CancellationToken to udapMetaData.TokenEndpointAuthSigningAlgValuesSupported = udapMetadataConfig.SignedMetadataConfig.TokenSigningAlgorithms; } + if (udapMetadataConfig.UdapCertificationsSupported != null && udapMetadataConfig.UdapCertificationsSupported.Count != 0) + { + udapMetaData.UdapCertificationsSupported = udapMetadataConfig.UdapCertificationsSupported; + } + + if (udapMetadataConfig.UdapCertificationsRequired != null && udapMetadataConfig.UdapCertificationsRequired.Count != 0) + { + udapMetaData.UdapCertificationsRequired = udapMetadataConfig.UdapCertificationsRequired; + } + var certificate = await Load(udapMetadataConfig, baseUrl, token); if (certificate == null) diff --git a/Udap.Custom.TrustStore/docs/README.md b/Udap.Custom.TrustStore/docs/README.md index 663400ce1..23f7da8cb 100644 --- a/Udap.Custom.TrustStore/docs/README.md +++ b/Udap.Custom.TrustStore/docs/README.md @@ -4,6 +4,6 @@ ## 📦 This package -This package would be used if your target OS is Windows and you cannot load certificates into the Windows Certificate Store. Currently this package is not used by in any code or examples. It will most likely be removed in favor of new abilities in dotnet core that allow us to load our own custom trust store. +> **Note:** This is an internal package and is not published to NuGet. -Actually this is not a published package. +This package provides an alternative trust store implementation for Windows environments where certificates cannot be loaded into the Windows Certificate Store. It may be superseded by newer .NET capabilities for loading custom trust stores. diff --git a/Udap.Metadata.Server/docs/README.md b/Udap.Metadata.Server/docs/README.md index 3066cf025..5fb49b02a 100644 --- a/Udap.Metadata.Server/docs/README.md +++ b/Udap.Metadata.Server/docs/README.md @@ -2,69 +2,60 @@ ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 Nuget Package: [Udap.Client](https://www.nuget.org/packages?q=udap.metadata.server) +## 📦 Nuget Package: [Udap.Metadata.Server](https://www.nuget.org/packages/Udap.Metadata.Server) -This package includes a MVC controller, an extension method to load, and an implementation if `ICertificateStore` as `FileCertificateStore` so you can get a sample up and running quickly. +This package provides the `.well-known/udap` metadata endpoint for FHIR resource servers and other UDAP-secured APIs. It includes an extension method for service registration, middleware for dynamic metadata serving, and a built-in `FileCertificateStore` implementation of `ICertificateStore`. -Program.cs could be as easy as this example. +For multi-domain metadata support (serving signed metadata for multiple domains within a single community), see [Multi-Domain Metadata Support](../../docs/multi-domain-metadata.md). -```csharp +## Quick Start + +Program.cs can be as simple as: -using Udap.Common; +```csharp using Udap.Metadata.Server; var builder = WebApplication.CreateBuilder(args); builder.Services .AddControllers() - .UseUdapMetaDataServer(builder.Configuration); - -builder.Services.AddSingleton(); + .AddUdapMetadataServer(builder.Configuration); +var app = builder.Build(); +app.UseUdapMetadataServer(); +app.MapControllers(); +app.Run(); ``` -## Full Example - -Below is a full example. Alternatively the [2023 FHIR® DevDays Tutorial](udap-devdays-2023) is another great way to learn how to use ```Udap.Metadata.Server```. - -Add this package to your FHIR® server or any web api server to. - -```csharp - -dotnet new sln -o WebApiProject1 -cd WebApiProject1 - -dotnet new webapi -o WebApi1 -minimal -dotnet sln add ./WebApi1/WebApi1.csproj - -cd WebApi1 - -``` +You can provide your own certificate store implementation: ```csharp -dotnet add package Udap.Metadata.Server +builder.Services.AddSingleton(); ``` -Or until a first release use the --prerelease tag. - -```csharp - -dotnet add package Udap.Metadata.Server --prerelease - -dotnet build +## Setup +```bash +dotnet add package Udap.Metadata.Server ``` -Add the UseUdapMetaDataServer service extension to program.cs +Add the `AddUdapMetadataServer` service extension and `UseUdapMetadataServer` middleware to Program.cs: ```csharp - - builder.Services +builder.Services .AddControllers() - .AddUdapMetaDataServer(builder.Configuration); + .AddUdapMetadataServer(builder.Configuration); +// ... + +// Place before UseRouting() and UseAuthentication() so metadata +// requests are handled anonymously +app.UseUdapMetadataServer(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); ``` -By default, ```AddUdapMetaDataServer``` registers the default ```IUdapMetadataOptionsProvider``` implmentation of ```UdapMetadataOptionsProvider```. ```UdapMetadataOptionsProvider``` finds the UdapMetadataOptionsFile in AppSettings +By default, `AddUdapMetadataServer` registers `UdapMetadataOptionsProvider` which reads the metadata options file path from AppSettings: ```json "UdapMetadataOptionsFile": "udap.metadata.options.json" @@ -72,41 +63,36 @@ By default, ```AddUdapMetaDataServer``` registers the default ```IUdapMetadataOp udap.metadata.options.json: ```json - { "UdapVersionsSupported": [ "1" ], - "UdapProfilesSupported": [ "udap_dcr", "udap_authn", "udap_authz", "udap_to" ], - "UdapAuthorizationExtensionsSupported": [ "hl7-b2b" ], - "UdapAuthorizationExtensionsRequired": [ "hl7-b2b" ], - "ScopesSupported": [ "openid", "system/*.read", "user/*.read", "patient/*.read" ], - "UdapCertificationsSupported": [ "http://MyUdapCertification", "http://MyUdapCertification2" ], - "UdapCertificationsRequired": [ "http://MyUdapCertification" ], - "GrantTypesSupported": [ "authorization_code", "refresh_token", "client_credentials" ], - //"TokenEndpointAuthSigningAlgValuesSupported": [ "RS256", "RS384", "ES256", "ES384" ], - //"RegistrationEndpointJwtSigningAlgValuesSupported": [ "RS256", "RS384", "ES256", "ES384" ], - - "UdapMetadataConfigs": [ - { - "Community": "http://localhost", - "SignedMetadataConfig": { - "AuthorizationEndpoint": "https://securedcontrols.net:5001/connect/authorize", - "TokenEndpoint": "https://securedcontrols.net:5001/connect/token", - "RegistrationEndpoint": "https://securedcontrols.net:5001/connect/register" - } + "UdapProfilesSupported": [ "udap_dcr", "udap_authn", "udap_authz", "udap_to" ], + "ScopesSupported": [ "openid", "system/*.read", "user/*.read", "patient/*.read" ], + "UdapCertificationsSupported": [ "http://MyUdapCertification", "http://MyUdapCertification2" ], + "UdapCertificationsRequired": [ "http://MyUdapCertification" ], + "GrantTypesSupported": [ "authorization_code", "refresh_token", "client_credentials" ], + + "UdapMetadataConfigs": [ + { + "Community": "http://localhost", + "SignedMetadataConfig": { + "AuthorizationEndpoint": "https://securedcontrols.net:5001/connect/authorize", + "TokenEndpoint": "https://securedcontrols.net:5001/connect/token", + "RegistrationEndpoint": "https://securedcontrols.net:5001/connect/register" } - ] + } + ] } ``` -## UDAP Metadata Options: see [Required UDAP Metadata](http://hl7.org/fhir/us/udap-security/discovery.html#signed-metadata-elements) +## UDAP Metadata Options -The `UdapMetadataOptions` class defines the configurable properties for UDAP metadata, as seen above in `udap.metadata.options.json`. +See [Required UDAP Metadata](http://hl7.org/fhir/us/udap-security/discovery.html#signed-metadata-elements). -### Known Properties +The `UdapMetadataOptions` class defines the configurable properties: - **UdapVersionsSupported**: Array of supported UDAP versions (e.g., `["1"]`) - **UdapProfilesSupported**: Array of supported UDAP profiles (e.g., `["udap_dcr", "udap_authn"]`) -- **UdapAuthorizationExtensionsSupported**: Array of supported authorization extensions +- **UdapAuthorizationExtensionsSupported**: Array of supported authorization extensions (e.g., `["hl7-b2b", "tefca-ias"]`) - **UdapAuthorizationExtensionsRequired**: Array of required authorization extensions - **UdapCertificationsSupported**: Array of supported certifications - **UdapCertificationsRequired**: Array of required certifications @@ -119,39 +105,20 @@ The `UdapMetadataOptions` class defines the configurable properties for UDAP met ### Extending Metadata -You can add additional custom properties to your `udap.metadata.options.json` file. Any extra properties not explicitly defined above will be loaded and made available in the published metadata via the `ExtensionData` dictionary. This allows for flexible extension of the metadata without modifying the core model. - +Any extra properties in your `udap.metadata.options.json` file not listed above will be loaded and made available in the published metadata via the `ExtensionData` dictionary. ## Certificate Store -The settings in ```udap.metadata.options.json``` will match the IssuedCerts settings in UdapFileCertStoreManifest settings of the appsettings.json. See below. - -To serve UDAP metadata, certificates will be loaded through an implementation of ```ICertificatStore```. Below is the built-in file-based implementation for lab experiments. +To serve UDAP metadata, certificates are loaded through an implementation of `ICertificateStore`. Below is the built-in file-based implementation: ```csharp -// UDAP CertStore builder.Services.Configure(builder.Configuration.GetSection("UdapFileCertStoreManifest")); builder.Services.AddSingleton(); ``` -To continue this example, copy the following files from the Udap.PKI.Generator test project output to the following directory structure at the root of the WebApi1 project. Ensure each file's "Copy to Output Directory" is set to copy. - -- CertStore - - issued - - weatherApiClientLocalhostCert.pfx - -Add configuration to AppSettings to point to the certificates. - -**Note From AppSettings** - -UdapMetadataOptions:UdapMetadataConfigs:Community value is the link to UdapFileCertStoreManifest:ResourceServers:Communities.Name. In this example the community is identified by the name `http://localhost`. Community names are [constrained as a URI](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) +Configure certificate paths in appsettings.json. The community name links `UdapMetadataConfigs` to `UdapFileCertStoreManifest`. Community names are [constrained as a URI](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities). ```json -/* - Normally put someplace safer like secrets.json or secured database - and add this to Program.cs. -*/ - "UdapFileCertStoreManifest": { "Communities": [ { @@ -163,77 +130,17 @@ UdapMetadataOptions:UdapMetadataConfigs:Community value is the link to UdapFileC } ] } - ] + ] } ``` -```csharp -dotnet run -``` +## Examples -Navigate to http://localhost:5079/.well-known/udap or http://localhost:5079/swagger. - -At this point a success would result in a result similar to the following json. Ensure the signed_metadata property contains a signed JWT token. - -
View Metadata - -```json -{ - "udap_versions_supported": [ - "1" - ], - "udap_profiles_supported": [ - "udap_dcr", - "udap_authn", - "udap_authz" - ], - "udap_authorization_extensions_supported": [ - "hl7-b2b" - ], - "udap_authorization_extensions_required": [ - "hl7-b2b" - ], - "udap_certifications_supported": [ - "http://MyUdapCertification", - "http://MyUdapCertification2" - ], - "udap_certifications_required": [ - "http://MyUdapCertification" - ], - "grant_types_supported": [ - "client_credentials" - ], - "scopes_supported": [ - "openid", - "system/Patient.read", - "system/AllergyIntolerance.read", - "system/Procedures.read", - "system/Observation.read" - ], - "authorization_endpoint": "https://securedcontrols.net/connect/authorize", - "token_endpoint": "https://securedcontrols.net/connect/token", - "token_endpoint_auth_methods_supported": [ - "private_key_jwt" - ], - "token_endpoint_auth_signing_alg_values_supported": [ - "RS256" - ], - "registration_endpoint": "https://securedcontrols.net/connect/register", - "registration_endpoint_jwt_signing_alg_values_supported": [ - "RS256" - ], - "signed_metadata": "eyJhbGciOiJSUzI1NiIsIng1YyI6WyJNSUlGR3pDQ0JBT2dBd0lCQWdJSUZSVVJqcWdlTkdNd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z2JNeEN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUlEQXBEWVd4cFptOXlibWxoTVJJd0VBWURWUVFIREFsVFlXNGdSR2xsWjI4eEV6QVJCZ05WQkFvTUNrVk5VaUJFYVhKbFkzUXhQekE5QmdOVkJBc01ObFJsYzNRZ1VFdEpJRU5sY25ScFptbGpZWFJwYjI0Z1FYVjBhRzl5YVhSNUlDaGpaWEowY3k1bGJYSmthWEpsWTNRdVkyOXRLVEVsTUNNR0ExVUVBd3djUlUxU0lFUnBjbVZqZENCVVpYTjBJRU5zYVdWdWRDQlRkV0pEUVRBZUZ3MHlNakE1TVRVeU1ETXpOVEphRncweU16QTVNVFV5TURNek5USmFNSUdwTVFzd0NRWURWUVFHRXdKVlV6RVBNQTBHQTFVRUNBd0dUM0psWjI5dU1TZ3dKZ1lEVlFRS0RCOVRkWEpsYzJOeWFYQjBjeUJNVEVNZ0tITmxiR1lnWVhOelpYSjBaV1FwTVRNd01RWURWUVFMRENwVlJFRlFJRlJsYzNRZ1EyVnlkR2xtYVdOaGRHVWdUazlVSUVaUFVpQlZVMFVnVjBsVVNDQlFTRWt4S2pBb0JnTlZCQU1NSVdoMGRIQnpPaTh2Wm1ocGNteGhZbk11Ym1WME9qY3dNVFl2Wm1ocGNpOXlORENDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFJQkgrSUtIRUJ4SDIyN09BYkRsTGYxS0k4b1UxZE8vZmp2ZzFQbkJNSlQ0RjQrL1BFWmlOdkRhS0dFT09lOXVvTmVMdGlEWEt0aFVQSEdEMm54RXVSL2lQeXluVmFETmtHYkZvc2d3c01JMXU4bGFJbHNwQWVrR2d5VWlPZzB3a1NRbEF4TjJuaFVqR3dMbjllUzBPWld0eGhUcHBNNEFGbElwY1hackFLeTlOZm53S2NGeUtvUmg3Zlo4bDlSR1hHeFl6ZXh2ejJ0LzhCbG5xb3ZQODZlWktHaFBxTTlFTGZPNTc4R1UrNWJCcFNqWUdsenhwemVnanZaUkR5bnBVbEJBdEtvWDBOdXh6ZjJ6SURvOVZwaldoVG9TKzZ0eDZJRFVNZVdEZHZjQytPQnNTNjNUdisxN2VFSVdpRjlGb0xNYUNUZXJRMFluaWlwVGQ3NDdGT2NDQXdFQUFhT0NBVGt3Z2dFMU1Ga0dDQ3NHQVFVRkJ3RUJCRTB3U3pCSkJnZ3JCZ0VGQlFjd0FvWTlhSFIwY0RvdkwyTmxjblJ6TG1WdGNtUnBjbVZqZEM1amIyMHZZMlZ5ZEhNdlJVMVNSR2x5WldOMFZHVnpkRU5zYVdWdWRGTjFZa05CTG1OeWREQWRCZ05WSFE0RUZnUVVuMDUzdk9jYVdINzRsR1c4VVlYazk4WU5nOUV3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlNqbFcxcnZTdFJ6ZUhQNVpCdjF5WlB2OTArM2pCTUJnTlZIUjhFUlRCRE1FR2dQNkE5aGp0b2RIUndPaTh2WTJWeWRITXVaVzF5WkdseVpXTjBMbU52YlM5amNtd3ZSVTFTUkdseVpXTjBWR1Z6ZEVOc2FXVnVkRk4xWWtOQkxtTnliREFPQmdOVkhROEJBZjhFQkFNQ0I0QXdMQVlEVlIwUkJDVXdJNFloYUhSMGNITTZMeTltYUdseWJHRmljeTV1WlhRNk56QXhOaTltYUdseUwzSTBNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUI1VkswWkhWZXpMdUYvY2FieW1ZOWFLa0pENXhxY0JWVFNjeGVYQ3NMaWloLzhFS0NwdmVVSWl6NDJ5U3JtbHBJS2ljby95c1ByWHZKbU8vVnJHMjFWbnpZNkZKQjE3empXbkQ2bncvRnRFNXU0V2laTTE2aGcxUzJpa01FYXMzRjU3L3FrYjNLMzdXUm1IVDdickphUUtGZFYzWWRrVFloZ1cvbjFTellqWnEwZ0w0bDZWcVBSeCsxSWpaUkQxNWowZVFOV1hrR1lvWmlsR3duSFFJOUhKSGxadmMxZ1VLeFl2dDhwR2hlL0ZwZmF0cW9QVlhVY09CRVlBTHNrNmdlUDBhR0Z1M0xQa3NxdjZpZTM2M01tZWp5WEtxeE1uUThHcUR1bVNBU1ZhbDhyVmw4ZjE1NzlwUDc4aGxDYWNzam4zdTBnNVJLRDVPUk4rQTlJTTRDMyJdfQ.eyJpc3MiOiJodHRwczovL3N0YWdlLmhlYWx0aHRvZ28ubWU6ODE4MSIsInN1YiI6Imh0dHBzOi8vc3RhZ2UuaGVhbHRodG9nby5tZTo4MTgxIiwiaWF0IjoxNjc2OTM3NjI3LCJleHAiOjE2NzY5Mzc2ODcsImp0aSI6Ik95N0RaenVhXzBYbDhEaFNRXzVONzFxeHFBcllLdEI3OUdmRkVGQVFaUkUiLCJhdXRob3JpemF0aW9uX2VuZHBvaW50IjoiaHR0cHM6Ly9zZWN1cmVkY29udHJvbHMubmV0L2Nvbm5lY3QvYXV0aG9yaXplIiwidG9rZW5fZW5kcG9pbnQiOiJodHRwczovL3NlY3VyZWRjb250cm9scy5uZXQvY29ubmVjdC90b2tlbiIsInJlZ2lzdHJhdGlvbl9lbmRwb2ludCI6Imh0dHBzOi8vc2VjdXJlZGNvbnRyb2xzLm5ldC9jb25uZWN0L3JlZ2lzdGVyIn0.Y9qWVQFs9HXWipN8YDrH7gf89FoA0V7f3p9vqc6bPuqrcI0B6wgqZ2ZC3FYi46nGvpe6G_H20edXYR7zIHqcXqhtjfYNmCYoH-ceVwvq6kCAm0c4v8BXN23SM1Eh72_481Bbf7PidHUzcAIOn7fJ9DAk-LiVsT9aa7TD2Aj11cLC5ZiuoHyLCOaf6sjK-yX707ov313TEQREgLbSnl-YTwbIgmm_h3fW4eSZH2eszdr3a3Q8BWKKVBphWos5TvQ77WsYfTt60JfFHEXO8Psq7n4bGm2ZcNApzoa9PIuimmzeN8vjyaLBu7lDi93cc9jKphYz3KpLh_-8ruHF2HqmNw" -} -``` - -
-
- -### UDAP Resource Server Examples - -- [FhirLabsApi example project](./examples//FhirLabsApi/) +- [FhirLabsApi example project](../../examples/FhirLabsApi/) - [FhirLabs Published](https://fhirlabs.net/fhir/r4/.well-known/udap) -- [FhirLabs UdapEd Tool | Discovery | Registration | B2B | Patient Match | National Directory ](https://udaped.fhirlabs.net) +- [UdapEd Tool](https://udaped.fhirlabs.net) + +--- - FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7. -- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. UDAP Draft Specifications are referenced and displayed in parts of this source code to document specification implementation. +- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. diff --git a/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs b/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs index 7a564872b..4cd9dfd86 100644 --- a/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs +++ b/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs @@ -1,8 +1,8 @@ -#region (c) 2023 Joseph Shook. All rights reserved. +#region (c) 2023 Joseph Shook. All rights reserved. // /* // Authors: // Joseph Shook Joseph.Shook@Surescripts.com -// +// // See LICENSE in the project root for license information. // */ #endregion @@ -19,7 +19,7 @@ namespace Udap.Model.Access; /// -/// +/// /// public class AccessTokenRequestForAuthorizationCodeBuilder { @@ -30,18 +30,18 @@ public class AccessTokenRequestForAuthorizationCodeBuilder private readonly string? _code; private readonly string? _redirectUri; private readonly DateTime _now; - private readonly X509Certificate2 _certificate; + private readonly List _certificates; - private AccessTokenRequestForAuthorizationCodeBuilder(string? clientId, string? tokenEndpoint, X509Certificate2 certificate, string? redirectUri, string? code) + private AccessTokenRequestForAuthorizationCodeBuilder(string? clientId, string? tokenEndpoint, List certificates, string? redirectUri, string? code) { _now = DateTime.UtcNow.ToUniversalTime(); _tokenEndpoint = tokenEndpoint; _clientId = clientId; - _certificate = certificate; + _certificates = certificates; _code = code; _redirectUri = redirectUri; - + _claims = [ new Claim(JwtClaimTypes.IssuedAt, EpochTime.GetIntDate(_now).ToString(), ClaimValueTypes.Integer), @@ -57,7 +57,21 @@ private AccessTokenRequestForAuthorizationCodeBuilder(string? clientId, string? public static AccessTokenRequestForAuthorizationCodeBuilder Create(string? clientId, string? tokenEndpoint, X509Certificate2 certificate, string? redirectUri, string? code) { - return new AccessTokenRequestForAuthorizationCodeBuilder(clientId, tokenEndpoint, certificate, redirectUri, code); + return new AccessTokenRequestForAuthorizationCodeBuilder(clientId, tokenEndpoint, new List { certificate }, redirectUri, code); + } + + /// + /// Create a builder with an ordered certificate chain where the first certificate is the end entity certificate. + /// + /// + /// + /// Certificate chain where the first certificate is the end entity certificate + /// + /// + /// + public static AccessTokenRequestForAuthorizationCodeBuilder Create(string? clientId, string? tokenEndpoint, List certificates, string? redirectUri, string? code) + { + return new AccessTokenRequestForAuthorizationCodeBuilder(clientId, tokenEndpoint, certificates, redirectUri, code); } /// @@ -107,7 +121,7 @@ private string BuildClientAssertion(string? algorithm) ); return SignedSoftwareStatementBuilder - .Create(_certificate, jwtPayload) + .Create(_certificates, jwtPayload) .Build(algorithm); } -} \ No newline at end of file +} diff --git a/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs b/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs index 80c7c9c91..f93afb022 100644 --- a/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs +++ b/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs @@ -1,8 +1,8 @@ -#region (c) 2023 Joseph Shook. All rights reserved. +#region (c) 2023 Joseph Shook. All rights reserved. // /* // Authors: // Joseph Shook Joseph.Shook@Surescripts.com -// +// // See LICENSE in the project root for license information. // */ #endregion @@ -20,25 +20,25 @@ namespace Udap.Model.Access; /// -/// +/// /// public class AccessTokenRequestForClientCredentialsBuilder { - + private readonly List _claims; private readonly string? _tokenEndoint; private readonly string? _clientId; private readonly DateTime _now; - private readonly X509Certificate2 _certificate; + private readonly List _certificates; private string? _scope; private readonly Dictionary _extensions = []; - private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string? tokenEndpoint, X509Certificate2 certificate) + private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string? tokenEndpoint, List certificates) { _now = DateTime.UtcNow.ToUniversalTime(); _tokenEndoint = tokenEndpoint; _clientId = clientId; - _certificate = certificate; + _certificates = certificates; _claims = [ @@ -46,7 +46,7 @@ private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string? new Claim(JwtClaimTypes.JwtId, CryptoRandom.CreateUniqueId()) // new Claim(UdapConstants.JwtClaimTypes.Extensions, BuildHl7B2BExtensions() ) //see http://hl7.org/fhir/us/udap-security/b2b.html#constructing-authentication-token ]; - + if (_clientId != null) { _claims.Add(new Claim(JwtClaimTypes.Subject, _clientId)); @@ -55,9 +55,21 @@ private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string? public static AccessTokenRequestForClientCredentialsBuilder Create(string? clientId, string? tokenEndpoint, X509Certificate2 certificate) { - return new AccessTokenRequestForClientCredentialsBuilder(clientId, tokenEndpoint, certificate); + return new AccessTokenRequestForClientCredentialsBuilder(clientId, tokenEndpoint, new List { certificate }); + } + + /// + /// Create a builder with an ordered certificate chain where the first certificate is the end entity certificate. + /// + /// + /// + /// Certificate chain where the first certificate is the end entity certificate + /// + public static AccessTokenRequestForClientCredentialsBuilder Create(string? clientId, string? tokenEndpoint, List certificates) + { + return new AccessTokenRequestForClientCredentialsBuilder(clientId, tokenEndpoint, certificates); } - + /// /// Add more claims /// @@ -74,7 +86,7 @@ public AccessTokenRequestForClientCredentialsBuilder WithScope(string scope) _scope = scope; return this; } - + public AccessTokenRequestForClientCredentialsBuilder WithExtension(string key, T value) where T : class { var jsonElement = JsonSerializer.Deserialize(JsonSerializer.Serialize(value)); @@ -104,7 +116,7 @@ public UdapClientCredentialsTokenRequest Build(string? algorithm = UdapConstants Scope = _scope, }; } - + private string BuildClientAssertion(string? algorithm) { @@ -123,9 +135,9 @@ private string BuildClientAssertion(string? algorithm) var payload = jwtPayload as Dictionary; payload.Add(UdapConstants.JwtClaimTypes.Extensions, _extensions); } - + return SignedSoftwareStatementBuilder - .Create(_certificate, jwtPayload) + .Create(_certificates, jwtPayload) .Build(algorithm); } -} \ No newline at end of file +} diff --git a/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs b/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs index d51d99249..3c52fb16c 100644 --- a/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs +++ b/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs @@ -254,6 +254,14 @@ public UdapCertificationAndEndorsementDocument(string certificationName) [JsonPropertyName(UdapConstants.CertificationAndEndorsementDocumentValues.Jwks)] public string? Jwks { get; set; } + /// + /// Additional claims not modeled as explicit properties. + /// Any unknown JSON properties will be captured here during deserialization + /// and included during serialization. + /// + [JsonExtensionData] + public Dictionary? AdditionalClaims { get; set; } + /// /// Serializes this instance to JSON. /// diff --git a/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs b/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs index d5db9f525..a2c77336b 100644 --- a/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs +++ b/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs @@ -9,6 +9,7 @@ using System.Security.Cryptography.X509Certificates; using System; +using System.Text.Json; using Microsoft.IdentityModel.Tokens; using Duende.IdentityModel; using System.Collections.Generic; @@ -639,6 +640,17 @@ public UdapCertificationsAndEndorsementBuilder WithCertificate(X509Certificate2 return this; } + + /// + /// Set additional claims that are not modeled as explicit properties on + /// . + /// These claims will be included as top-level properties in the signed JWT payload. + /// + public UdapCertificationsAndEndorsementBuilder WithAdditionalClaims(Dictionary? additionalClaims) + { + _document.AdditionalClaims = additionalClaims; + return this; + } public UdapCertificationAndEndorsementDocument Build() { diff --git a/Udap.Model/UdapMetadataConfig.cs b/Udap.Model/UdapMetadataConfig.cs index d41c68fa2..c3c4ab1d4 100644 --- a/Udap.Model/UdapMetadataConfig.cs +++ b/Udap.Model/UdapMetadataConfig.cs @@ -7,6 +7,8 @@ // */ #endregion +using System.Collections.Generic; + namespace Udap.Model; public class UdapMetadataConfig @@ -17,6 +19,17 @@ public class UdapMetadataConfig /// public string Community { get; set; } = string.Empty; + /// + /// Per-community override for udap_certifications_supported. + /// When set, replaces the root-level . + /// + public HashSet? UdapCertificationsSupported { get; set; } + + /// + /// Per-community override for udap_certifications_required. + /// When set, replaces the root-level . + /// + public HashSet? UdapCertificationsRequired { get; set; } ///
/// See Signed metadata elements diff --git a/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs b/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs index adef17198..33bdd4627 100644 --- a/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs +++ b/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs @@ -51,5 +51,10 @@ public interface IUdapClientRegistrationStore Task GetAnchorsCertificates(string? community, CancellationToken token = default); Task GetCommunityId(string community, CancellationToken token = default); + /// + /// Reverse lookup: resolves a community name (URI) from a community ID. + /// + Task GetCommunityName(string communityId, CancellationToken token = default); + Task?> RolloverClientSecrets(ParsedSecret secret, CancellationToken token = default); } \ No newline at end of file diff --git a/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs b/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs index f5ebed332..fe6121812 100644 --- a/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs +++ b/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs @@ -287,6 +287,21 @@ public async Task> GetAnchors(string? community, Cancellatio .SingleOrDefaultAsync(token); } + public async Task GetCommunityName(string communityId, CancellationToken token = default) + { + using var activity = Tracing.StoreActivitySource.StartActivity(); + + if (!int.TryParse(communityId, out var id)) + { + return null; + } + + return await _dbContext.Communities + .Where(c => c.Id == id) + .Select(c => c.Name) + .SingleOrDefaultAsync(token); + } + public async Task?> RolloverClientSecrets(ParsedSecret secret, CancellationToken token = default) { var rolled = false; diff --git a/Udap.Server.Storage/UdapServerConstants.cs b/Udap.Server.Storage/UdapServerConstants.cs index a6c83b748..b1f08d5d5 100644 --- a/Udap.Server.Storage/UdapServerConstants.cs +++ b/Udap.Server.Storage/UdapServerConstants.cs @@ -27,5 +27,6 @@ public static class ClientPropertyConstants public static class HttpContextItems { public const string UdapErrorDescription = "udap:error_description"; + public const string UdapErrorExtensions = "udap:error_extensions"; } } diff --git a/Udap.Server/Configuration/CommunityServerSettings.cs b/Udap.Server/Configuration/CommunityServerSettings.cs deleted file mode 100644 index 8ea4a07b8..000000000 --- a/Udap.Server/Configuration/CommunityServerSettings.cs +++ /dev/null @@ -1,80 +0,0 @@ -#region (c) 2026 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Text.Json.Serialization; - -namespace Udap.Server.Configuration; - -/// -/// Per-community overrides for . -/// When a client belongs to a community that has a matching entry here, -/// these settings take precedence over the global defaults. -/// -public class CommunityServerSettings -{ - /// - /// The community name (e.g., "udap://fhirlabs.net") used to match - /// against the client's registered community. - /// - [JsonPropertyName("Community")] - public string Community { get; set; } = string.Empty; - - /// - /// Authorization extension key names required by this community in every - /// token request regardless of grant type (e.g., ["hl7-b2b"]). - /// When null, falls back to the global . - /// When grant-type-specific settings ( - /// or ) are set, they take precedence. - /// - [JsonPropertyName("AuthorizationExtensionsRequired")] - public HashSet? AuthorizationExtensionsRequired { get; set; } - - /// - /// Authorization extension key names required for client_credentials token requests - /// in this community (e.g., ["hl7-b2b"]). When set, takes precedence over - /// for client_credentials grants. - /// When null, falls back to , - /// then to the global . - /// - [JsonPropertyName("ClientCredentialsExtensionsRequired")] - public HashSet? ClientCredentialsExtensionsRequired { get; set; } - - /// - /// Authorization extension key names required for authorization_code token requests - /// in this community (e.g., ["hl7-b2b-user"]). When set, takes precedence over - /// for authorization_code grants. - /// When null, falls back to , - /// then to the global . - /// - [JsonPropertyName("AuthorizationCodeExtensionsRequired")] - public HashSet? AuthorizationCodeExtensionsRequired { get; set; } - - /// - /// Allowed purpose_of_use codes for this community. When set, every code - /// in the extension's purpose_of_use array must appear in this set. - /// When null, falls back to the global . - /// An empty set means no purpose_of_use codes are accepted. - /// - /// SSRAA communities typically use HL7 v3 PurposeOfUse codes - /// (e.g., "urn:oid:2.16.840.1.113883.5.8#TREAT"). - /// TEFCA communities use XP codes - /// (e.g., "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT"). - /// - /// - [JsonPropertyName("AllowedPurposeOfUse")] - public HashSet? AllowedPurposeOfUse { get; set; } - - /// - /// Maximum number of purpose_of_use entries allowed in the extension. - /// When null, falls back to the global . - /// TEFCA requires exactly 1; base SSRAA allows multiple. - /// - [JsonPropertyName("MaxPurposeOfUseCount")] - public int? MaxPurposeOfUseCount { get; set; } -} diff --git a/Udap.Server/Configuration/ServerSettings.cs b/Udap.Server/Configuration/ServerSettings.cs index 1d3a35f87..37af59944 100644 --- a/Udap.Server/Configuration/ServerSettings.cs +++ b/Udap.Server/Configuration/ServerSettings.cs @@ -92,56 +92,7 @@ public class ServerSettings /// public bool ForceDPoP { get; set; } - /// - /// Authorization extension key names required by this server in every token request - /// regardless of grant type (e.g., ["hl7-b2b"]). This is the global default; - /// per-community overrides can be specified in . - /// When grant-type-specific settings ( - /// or ) are set, they take precedence. - /// Validated by . - /// - [JsonPropertyName("AuthorizationExtensionsRequired")] - public HashSet? AuthorizationExtensionsRequired { get; set; } - - /// - /// Authorization extension key names required for client_credentials token requests - /// (e.g., ["hl7-b2b"]). When set, takes precedence over - /// for client_credentials grants. Per-community overrides in . - /// - [JsonPropertyName("ClientCredentialsExtensionsRequired")] - public HashSet? ClientCredentialsExtensionsRequired { get; set; } - - /// - /// Authorization extension key names required for authorization_code token requests - /// (e.g., ["hl7-b2b-user"]). When set, takes precedence over - /// for authorization_code grants. Per-community overrides in . - /// - [JsonPropertyName("AuthorizationCodeExtensionsRequired")] - public HashSet? AuthorizationCodeExtensionsRequired { get; set; } - /// - /// Allowed purpose_of_use codes (global default). When set, every code - /// in the extension's purpose_of_use array must appear in this set. - /// Null means no restriction. Per-community overrides in . - /// - [JsonPropertyName("AllowedPurposeOfUse")] - public HashSet? AllowedPurposeOfUse { get; set; } - - /// - /// Maximum number of purpose_of_use entries allowed (global default). - /// Null means no limit. TEFCA communities would set this to 1. - /// Per-community overrides in . - /// - [JsonPropertyName("MaxPurposeOfUseCount")] - public int? MaxPurposeOfUseCount { get; set; } - - /// - /// Per-community overrides for server settings. When a client belongs to a community - /// that has a matching entry here, the community-specific settings take precedence - /// over the global defaults. - /// - [JsonPropertyName("CommunitySettings")] - public List? CommunitySettings { get; set; } } diff --git a/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs b/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs index f6dc976c5..a066e1555 100644 --- a/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs +++ b/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs @@ -60,39 +60,67 @@ public async Task Invoke(HttpContext context) } if (context.Response.StatusCode is 400 or 401 - && context.Items.TryGetValue(UdapServerConstants.HttpContextItems.UdapErrorDescription, out var errorDescObj) - && errorDescObj is string errorDescription && !string.IsNullOrEmpty(responseBody)) { - try - { - using var doc = JsonDocument.Parse(responseBody); - var root = doc.RootElement; + var hasErrorDescription = + context.Items.TryGetValue(UdapServerConstants.HttpContextItems.UdapErrorDescription, out var errorDescObj) + && errorDescObj is string; + + var hasErrorExtensions = + context.Items.TryGetValue(UdapServerConstants.HttpContextItems.UdapErrorExtensions, out var errorExtObj) + && errorExtObj is Dictionary; - if (root.ValueKind == JsonValueKind.Object && !root.TryGetProperty("error_description", out _)) + if (hasErrorDescription || hasErrorExtensions) + { + try { - using var ms = new MemoryStream(); - using (var writer = new Utf8JsonWriter(ms)) + using var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Object) { - writer.WriteStartObject(); + var needsErrorDescription = hasErrorDescription + && !root.TryGetProperty("error_description", out _); + var needsExtensions = hasErrorExtensions + && !root.TryGetProperty("extensions", out _); - foreach (var property in root.EnumerateObject()) + if (needsErrorDescription || needsExtensions) { - property.WriteTo(writer); + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) + { + writer.WriteStartObject(); + + foreach (var property in root.EnumerateObject()) + { + property.WriteTo(writer); + } + + if (needsErrorDescription) + { + writer.WriteString("error_description", (string)errorDescObj!); + _logger.LogDebug("Injected error_description into token error response"); + } + + if (needsExtensions) + { + var extensions = (Dictionary)errorExtObj!; + writer.WritePropertyName("extensions"); + JsonSerializer.Serialize(writer, extensions); + _logger.LogDebug("Injected extensions into token error response"); + } + + writer.WriteEndObject(); + } + + responseBody = System.Text.Encoding.UTF8.GetString(ms.ToArray()); } - - writer.WriteString("error_description", errorDescription); - writer.WriteEndObject(); } - - responseBody = System.Text.Encoding.UTF8.GetString(ms.ToArray()); - - _logger.LogDebug("Injected error_description into token error response"); } - } - catch (JsonException ex) - { - _logger.LogDebug(ex, "Could not parse token response body for error_description injection"); + catch (JsonException ex) + { + _logger.LogDebug(ex, "Could not parse token response body for error injection"); + } } } diff --git a/Udap.Server/Registration/ICommunityRegistrationValidator.cs b/Udap.Server/Registration/ICommunityRegistrationValidator.cs new file mode 100644 index 000000000..8ebead4ff --- /dev/null +++ b/Udap.Server/Registration/ICommunityRegistrationValidator.cs @@ -0,0 +1,40 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Udap.Server.Registration; + +/// +/// Validates a UDAP registration request for a specific community. +/// Implementations are registered as a DI collection +/// (following the same pattern as IAuthorizationExtensionDeserializer) +/// and invoked for each registration whose resolved community matches. +/// +/// Called after core UDAP validation succeeds (JWT, chain, scopes) +/// but before the processor persists the client. +/// +public interface ICommunityRegistrationValidator +{ + /// + /// Returns true if this validator should run for the given community. + /// + /// + /// The community URI string (e.g., urn:oid:2.16.840.1.113883.3.7204.1.5). + /// + bool AppliesToCommunity(string communityName); + + /// + /// Validates community-specific rules on the registration context. + /// + /// + /// null if validation passes; a populated + /// with an error if rejected. + /// + Task ValidateAsync( + UdapDynamicClientRegistrationContext context); +} diff --git a/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs b/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs index a1eda32bf..9399c5fba 100644 --- a/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs +++ b/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs @@ -60,6 +60,11 @@ public class UdapDynamicClientRegistrationContext /// public long? CommunityId { get; set; } + /// + /// The community name (URI) resolved during chain validation. + /// + public string? CommunityName { get; set; } + /// /// Certificate expiration from the validated chain. /// diff --git a/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs b/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs index 741c28e36..a01a97c2d 100644 --- a/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs +++ b/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs @@ -27,17 +27,20 @@ public class UdapDynamicClientRegistrationEndpoint private readonly IUdapDynamicClientRegistrationValidator _validator; private readonly IUdapDynamicClientRegistrationProcessor _processor; private readonly IUdapClientRegistrationStore _store; + private readonly IEnumerable _communityRegistrationValidators; private readonly ILogger _logger; public UdapDynamicClientRegistrationEndpoint( IUdapDynamicClientRegistrationValidator validator, IUdapDynamicClientRegistrationProcessor processor, IUdapClientRegistrationStore store, + IEnumerable communityRegistrationValidators, ILogger logger) { _validator = validator; _processor = processor; _store = store; + _communityRegistrationValidators = communityRegistrationValidators; _logger = logger; } @@ -102,7 +105,6 @@ await httpContext.Response.WriteAsJsonAsync(new UdapDynamicClientRegistrationErr try { - // Not in pattern with other validators in IdentityServer. Typically, all errors handled in ValidateAsync... TODO if (communityTrustAnchors == null) { throw new NullReferenceException("Missing Community Trust Anchors"); @@ -142,6 +144,32 @@ await httpContext.Response.WriteAsJsonAsync(new UdapDynamicClientRegistrationErr return; } + // Community-specific registration validation + if (!string.IsNullOrEmpty(context.CommunityName)) + { + foreach (var communityValidator in _communityRegistrationValidators) + { + if (communityValidator.AppliesToCommunity(context.CommunityName)) + { + var communityResult = await communityValidator.ValidateAsync(context); + if (communityResult is { IsError: true }) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + var communityError = new UdapDynamicClientRegistrationErrorResponse( + communityResult.Error ?? string.Empty, + communityResult.ErrorDescription ?? string.Empty); + + _logger.LogWarning("Error: {@Error}", communityError); + + await httpContext.Response.WriteAsJsonAsync(communityError, cancellationToken: token); + + return; + } + } + } + } + // Process (create client + persist) try { diff --git a/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs b/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs index d0d85dfa7..90c1e4212 100644 --- a/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs +++ b/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs @@ -710,6 +710,7 @@ private async Task ValidateChainAsync( context.Issuer = jwtSecurityToken.Issuer; context.CommunityId = result.CommunityId; + context.CommunityName = result.CommunityName; context.CertificateExpiration = result.ChainElements.First().Certificate.NotAfter.ToUniversalTime(); return true; diff --git a/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs b/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs index fc99ef6eb..3440d6afb 100644 --- a/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs +++ b/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs @@ -289,6 +289,23 @@ public Task> GetAnchors(string? community, CancellationToken return Task.FromResult(id); } + public Task GetCommunityName(string communityId, CancellationToken token = default) + { + if (!int.TryParse(communityId, out var id)) + { + return Task.FromResult(null); + } + + // Use FirstOrDefault because in-memory communities may share default IDs (0) + // when tests don't assign explicit IDs. The EF store uses SingleOrDefaultAsync + // since the database enforces unique primary keys. + var name = _communities.Where(c => c.Id == id) + .Select(c => c.Name) + .FirstOrDefault(); + + return Task.FromResult(name); + } + public Task?> RolloverClientSecrets(ParsedSecret secret, CancellationToken token = default) { var rolled = false; diff --git a/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs b/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs index bff05e510..3fd211af0 100644 --- a/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs +++ b/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs @@ -7,6 +7,8 @@ // */ #endregion +using System.Collections.Generic; + namespace Udap.Server.Validation; /// @@ -18,9 +20,21 @@ public class AuthorizationExtensionValidationResult public string? Error { get; set; } public string? ErrorDescription { get; set; } + /// + /// Optional error extension data to include in the error response "extensions" object. + /// Keys are extension names (e.g., "hl7-b2b"), values are serializable objects. + /// Used by trust community profiles (e.g., TEFCA) to return additional error metadata + /// such as required consent policies. + /// + public Dictionary? ErrorExtensions { get; set; } + public static AuthorizationExtensionValidationResult Success() => new() { IsValid = true }; public static AuthorizationExtensionValidationResult Failure(string error, string description) => new() { IsValid = false, Error = error, ErrorDescription = description }; + + public static AuthorizationExtensionValidationResult Failure( + string error, string description, Dictionary errorExtensions) + => new() { IsValid = false, Error = error, ErrorDescription = description, ErrorExtensions = errorExtensions }; } diff --git a/Udap.Server/Validation/CommunityValidationRules.cs b/Udap.Server/Validation/CommunityValidationRules.cs new file mode 100644 index 000000000..e4b2cbce6 --- /dev/null +++ b/Udap.Server/Validation/CommunityValidationRules.cs @@ -0,0 +1,35 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Udap.Server.Validation; + +/// +/// Validation rules declared by a community token validator for a specific grant type. +/// When a returns non-null rules from +/// , these override +/// global ServerSettings for that community. +/// +public class CommunityValidationRules +{ + /// + /// Extension keys that must be present in the token request (e.g., "hl7-b2b"). + /// Null means no requirement; empty set means explicitly no extensions required. + /// + public HashSet? RequiredExtensions { get; init; } + + /// + /// Allowed purpose_of_use codes. Null means no restriction. + /// + public HashSet? AllowedPurposeOfUse { get; init; } + + /// + /// Maximum number of purpose_of_use entries allowed. Null means no limit. + /// + public int? MaxPurposeOfUseCount { get; init; } +} diff --git a/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs b/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs index 02cdd18e3..67eec7d40 100644 --- a/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs +++ b/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs @@ -8,166 +8,169 @@ #endregion using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Udap.Model; using Udap.Model.UdapAuthenticationExtensions; -using Udap.Server.Configuration; using Udap.Server.Storage.Stores; namespace Udap.Server.Validation.Default; /// /// Default implementation of that -/// enforces required authorization extensions based on -/// with optional per-community overrides via . +/// enforces required authorization extensions via +/// implementations. +/// +/// Community validators provide rules via +/// (required extensions, allowed POU codes, max count) and domain-specific validation +/// via . +/// +/// When no community validator applies, no extension or POU enforcement occurs. /// public class DefaultUdapAuthorizationExtensionValidator : IUdapAuthorizationExtensionValidator { - private readonly IOptionsMonitor _serverSettings; private readonly IUdapClientRegistrationStore _clientStore; + private readonly IEnumerable _communityTokenValidators; private readonly ILogger _logger; public DefaultUdapAuthorizationExtensionValidator( - IOptionsMonitor serverSettings, IUdapClientRegistrationStore clientStore, + IEnumerable communityTokenValidators, ILogger logger) { - _serverSettings = serverSettings; _clientStore = clientStore; + _communityTokenValidators = communityTokenValidators; _logger = logger; } public async Task ValidateAsync( UdapAuthorizationExtensionValidationContext context) { - var resolved = await ResolveCommunitySettingsAsync(context.CommunityId, context.GrantType); + var resolved = await ResolveSettingsAsync(context.CommunityId, context.GrantType); var requiredExtensions = resolved.RequiredExtensions; - if (requiredExtensions == null || requiredExtensions.Count == 0) + if (requiredExtensions != null && requiredExtensions.Count > 0) { - return AuthorizationExtensionValidationResult.Success(); - } - - if (context.Extensions == null || context.Extensions.Count == 0) - { - _logger.LogError( - "Client {ClientId} did not include required authorization extensions: {Required}", - context.ClientId, string.Join(", ", requiredExtensions)); - - return AuthorizationExtensionValidationResult.Failure( - "invalid_grant", - $"Required authorization extension(s) missing: {string.Join(", ", requiredExtensions)}"); - } - - foreach (var required in requiredExtensions) - { - if (!context.Extensions.ContainsKey(required)) + if (context.Extensions == null || context.Extensions.Count == 0) { _logger.LogError( - "Client {ClientId} missing required authorization extension '{Extension}'", - context.ClientId, required); + "Client {ClientId} did not include required authorization extensions: {Required}", + context.ClientId, string.Join(", ", requiredExtensions)); return AuthorizationExtensionValidationResult.Failure( "invalid_grant", - $"Required authorization extension '{required}' not found"); + $"Required authorization extension(s) missing: {string.Join(", ", requiredExtensions)}"); + } + + foreach (var required in requiredExtensions) + { + if (!context.Extensions.ContainsKey(required)) + { + _logger.LogError( + "Client {ClientId} missing required authorization extension '{Extension}'", + context.ClientId, required); + + return AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + $"Required authorization extension '{required}' not found"); + } } } - foreach (var (key, value) in context.Extensions) + // Structural validation of extension objects + if (context.Extensions != null) { - var errors = ValidateExtensionObject(key, value); - - if (errors.Count > 0) + foreach (var (key, value) in context.Extensions) { - _logger.LogError( - "Client {ClientId} authorization extension '{Extension}' validation failed: {Errors}", - context.ClientId, key, string.Join("; ", errors)); + var errors = ValidateExtensionObject(key, value); - return AuthorizationExtensionValidationResult.Failure( - "invalid_grant", - $"Authorization extension '{key}' validation failed: {string.Join("; ", errors)}"); + if (errors.Count > 0) + { + _logger.LogError( + "Client {ClientId} authorization extension '{Extension}' validation failed: {Errors}", + context.ClientId, key, string.Join("; ", errors)); + + return AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + $"Authorization extension '{key}' validation failed: {string.Join("; ", errors)}"); + } + + var pouResult = ValidatePurposeOfUse( + key, value, resolved.AllowedPurposeOfUse, resolved.MaxPurposeOfUseCount, + context.ClientId); + + if (!pouResult.IsValid) + { + return pouResult; + } } + } - var pouResult = ValidatePurposeOfUse( - key, value, resolved.AllowedPurposeOfUse, resolved.MaxPurposeOfUseCount, - resolved.IsCommunityResolved, context.ClientId); + // Community-specific token validation — always runs regardless of whether + // extensions are required, so community validators can enforce additional + // rules (e.g., TEFCA purpose_of_use matching against registered SAN URI). + if (!string.IsNullOrEmpty(resolved.CommunityName)) + { + context.CommunityName = resolved.CommunityName; - if (!pouResult.IsValid) + foreach (var communityValidator in _communityTokenValidators) { - return pouResult; + if (communityValidator.AppliesToCommunity(resolved.CommunityName)) + { + var communityResult = await communityValidator.ValidateAsync(context); + if (!communityResult.IsValid) + { + return communityResult; + } + } } } return AuthorizationExtensionValidationResult.Success(); } - private async Task ResolveCommunitySettingsAsync(string? communityId, string? grantType) + private async Task ResolveSettingsAsync(string? communityId, string? grantType) { - var settings = _serverSettings.CurrentValue; + string? communityName = null; - if (communityId != null && settings.CommunitySettings is { Count: > 0 }) + // Resolve community name from store + if (communityId != null) { - foreach (var commSettings in settings.CommunitySettings) - { - var resolvedId = await _clientStore.GetCommunityId(commSettings.Community); + communityName = await _clientStore.GetCommunityName(communityId); + } - if (resolvedId?.ToString() == communityId) + // Check if a community validator provides rules for this community + if (communityName != null) + { + foreach (var validator in _communityTokenValidators) + { + if (validator.AppliesToCommunity(communityName)) { - return new ResolvedSettings + var rules = validator.GetValidationRules(grantType); + if (rules != null) { - IsCommunityResolved = true, - RequiredExtensions = ResolveRequiredExtensions( - grantType, - commSettings.ClientCredentialsExtensionsRequired, - commSettings.AuthorizationCodeExtensionsRequired, - commSettings.AuthorizationExtensionsRequired - ?? settings.AuthorizationExtensionsRequired), - AllowedPurposeOfUse = commSettings.AllowedPurposeOfUse, - MaxPurposeOfUseCount = commSettings.MaxPurposeOfUseCount - }; + return new ResolvedSettings + { + CommunityName = communityName, + RequiredExtensions = rules.RequiredExtensions, + AllowedPurposeOfUse = rules.AllowedPurposeOfUse, + MaxPurposeOfUseCount = rules.MaxPurposeOfUseCount + }; + } } } } + // No community validator matched — no extension or POU enforcement return new ResolvedSettings { - RequiredExtensions = ResolveRequiredExtensions( - grantType, - settings.ClientCredentialsExtensionsRequired, - settings.AuthorizationCodeExtensionsRequired, - settings.AuthorizationExtensionsRequired), - AllowedPurposeOfUse = settings.AllowedPurposeOfUse, - MaxPurposeOfUseCount = settings.MaxPurposeOfUseCount + CommunityName = communityName }; } - /// - /// Resolves the required extensions for the given grant type. - /// Grant-type-specific settings take precedence over the general fallback. - /// - private static HashSet? ResolveRequiredExtensions( - string? grantType, - HashSet? clientCredentialsExtensions, - HashSet? authorizationCodeExtensions, - HashSet? fallback) - { - var grantSpecific = grantType switch - { - "client_credentials" => clientCredentialsExtensions, - "authorization_code" => authorizationCodeExtensions, - _ => null - }; - - return grantSpecific ?? fallback; - } - private AuthorizationExtensionValidationResult ValidatePurposeOfUse( string extensionKey, object extensionValue, HashSet? allowedCodes, int? maxCount, - bool isCommunityResolved, string clientId) { var purposeOfUse = GetPurposeOfUse(extensionKey, extensionValue); @@ -190,17 +193,6 @@ private AuthorizationExtensionValidationResult ValidatePurposeOfUse( if (allowedCodes == null) { - if (isCommunityResolved) - { - _logger.LogError( - "Client {ClientId} extension '{Extension}' contains purpose_of_use but AllowedPurposeOfUse is not configured for the matched community", - clientId, extensionKey); - - return AuthorizationExtensionValidationResult.Failure( - "server_error", - $"AllowedPurposeOfUse is not configured for the matched community; extension '{extensionKey}' cannot be validated"); - } - return AuthorizationExtensionValidationResult.Success(); } @@ -243,7 +235,7 @@ private static List ValidateExtensionObject(string key, object value) private class ResolvedSettings { - public bool IsCommunityResolved { get; init; } + public string? CommunityName { get; init; } public HashSet? RequiredExtensions { get; init; } public HashSet? AllowedPurposeOfUse { get; init; } public int? MaxPurposeOfUseCount { get; init; } diff --git a/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs b/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs index 460c67078..c29cbbe95 100644 --- a/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs +++ b/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs @@ -9,6 +9,7 @@ using System.Text.Json; using Duende.IdentityServer.Validation; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Udap.Model; @@ -29,15 +30,18 @@ public class UdapCustomTokenRequestValidator : ICustomTokenRequestValidator { private readonly IUdapAuthorizationExtensionValidator _extensionValidator; private readonly IEnumerable _customDeserializers; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; public UdapCustomTokenRequestValidator( IUdapAuthorizationExtensionValidator extensionValidator, IEnumerable customDeserializers, + IHttpContextAccessor httpContextAccessor, ILogger logger) { _extensionValidator = extensionValidator; _customDeserializers = customDeserializers; + _httpContextAccessor = httpContextAccessor; _logger = logger; } @@ -81,13 +85,18 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context) .FirstOrDefault(s => s.Type == UdapServerConstants.SecretTypes.UDAP_COMMUNITY) ?.Value; + var sanUri = request.Client.ClientSecrets + .FirstOrDefault(s => s.Type == UdapServerConstants.SecretTypes.UDAP_SAN_URI_ISS_NAME) + ?.Value; + var extensionContext = new UdapAuthorizationExtensionValidationContext { ClientAssertionToken = jwtToken, ClientId = request.ClientId ?? string.Empty, Extensions = extensions, CommunityId = communityId, - GrantType = request.GrantType + GrantType = request.GrantType, + SanUri = sanUri }; var result = await _extensionValidator.ValidateAsync(extensionContext); @@ -98,6 +107,12 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context) "Authorization extension validation failed for client_id {ClientId}: {Error}", request.ClientId, result.ErrorDescription); + if (result.ErrorExtensions != null && _httpContextAccessor.HttpContext != null) + { + _httpContextAccessor.HttpContext.Items[UdapServerConstants.HttpContextItems.UdapErrorExtensions] = + result.ErrorExtensions; + } + context.Result = new TokenRequestValidationResult( request, result.Error ?? "invalid_grant", diff --git a/Udap.Server/Validation/ICommunityTokenValidator.cs b/Udap.Server/Validation/ICommunityTokenValidator.cs new file mode 100644 index 000000000..cd2849947 --- /dev/null +++ b/Udap.Server/Validation/ICommunityTokenValidator.cs @@ -0,0 +1,39 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Udap.Server.Validation; + +/// +/// Community-specific validation of authorization extensions at token time. +/// Implementations are registered as a DI collection and invoked when +/// the client belongs to a matching community. +/// +public interface ICommunityTokenValidator +{ + /// + /// Returns true if this validator should run for the given community. + /// + /// + /// The community URI string (e.g., urn:oid:2.16.840.1.113883.3.7204.1.5). + /// + bool AppliesToCommunity(string communityName); + + /// + /// Returns the validation rules this validator enforces for the given grant type, + /// or null to defer to global ServerSettings configuration. + /// When non-null, these rules override global settings for the matched community. + /// + CommunityValidationRules? GetValidationRules(string? grantType) => null; + + /// + /// Validates community-specific token-time rules. + /// + Task ValidateAsync( + UdapAuthorizationExtensionValidationContext context); +} diff --git a/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs b/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs index f49190716..705764902 100644 --- a/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs +++ b/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs @@ -44,4 +44,17 @@ public class UdapAuthorizationExtensionValidationContext /// Used to resolve grant-type-specific extension requirements. /// public string? GrantType { get; set; } + + /// + /// The SAN URI the client registered with (from the UDAP_SAN_URI_ISS_NAME secret). + /// Community validators use this to enforce that the purpose_of_use matches + /// the exchange purpose embedded in the SAN. + /// + public string? SanUri { get; set; } + + /// + /// The community name (URI) the client belongs to. + /// Used by implementations to determine applicability. + /// + public string? CommunityName { get; set; } } diff --git a/Udap.Server/docs/README.md b/Udap.Server/docs/README.md index 29a491633..748cda287 100644 --- a/Udap.Server/docs/README.md +++ b/Udap.Server/docs/README.md @@ -2,20 +2,33 @@ ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 Nuget Package: [Udap.Server](https://www.nuget.org/packages?q=udap.server) +## 📦 Nuget Package: [Udap.Server](https://www.nuget.org/packages/Udap.Server) -This package is intended to be used with Duende's Identity Server. Duende must be licensed if you are using it for anything more than testing and you make more than 1 million dollars a year. I am willing to build samples using other identity providers. Keep in mind `Udap.Server` is meant to add auto registration only. Then it relies on Identity Server for identity rather than build a complete identity provider server. There is great value Duende provides that would take a very long time to get correct. +This package adds UDAP Dynamic Client Registration (DCR) and metadata capabilities to an authorization server built on Duende IdentityServer. It provides the `.well-known/udap` metadata endpoint and the `/connect/register` DCR endpoint as extensions to the IdentityServer pipeline. -This package contains a few extension methods and two endpoints. The first endpoint is an UDAP metadata endpoint, implementing Duende's IEndpointHandler interface allowing /.well-known/udap to render metadata. The items of interest in the metadata is the registration_endpoint which points to the next endpoint; `UdapDynamicClientRegistrationEndpoint`. This code is a simple endpoint called as a delegate using the dotnet minimal api technique. All of this is configured by adding the ```AddUdapServer()``` extension method to the Identity Server pipeline. +> **Note:** Duende IdentityServer requires a [license](https://duendesoftware.com/products/identityserver) for production use above $1M annual revenue. -**Assumptions:** An Identity Sever exists is backed by a relational database. Use [Udap.Auth.Server](./../examples/Udap.Auth.Server/) as an example. I may revisit this in the future and build an in memory version but this reference implementation. For now it assumes a relational database is deployed. +## Features + +- UDAP metadata endpoint (`.well-known/udap`) +- Dynamic Client Registration (create, update, cancel) +- Multi-community trust anchor support +- Authorization Extension Object (AEO) enforcement via `IUdapAuthorizationExtensionValidator` +- Tiered OAuth support + +### Profile-Specific Validation + +For SSRAA or TEFCA community-specific validation rules, add the corresponding packages: + +- [`Udap.Ssraa.Server`](https://www.nuget.org/packages/Udap.Ssraa.Server) — HL7 v3 PurposeOfUse enforcement +- [`Udap.Tefca.Server`](https://www.nuget.org/packages/Udap.Tefca.Server) — TEFCA Exchange Purpose (XP) code validation, SAN matching +- [`Udap.Tefca.Model`](https://www.nuget.org/packages/Udap.Tefca.Model) — TEFCA extension models (`tefca-ias`, `tefca_smart`) ## Full Example -Below is a full example. Alternatively the [2023 FHIR® DevDays Tutorial](udap-devdays-2023) is another great way to learn how to use ```Udap.Server```. +Below is a full example. See also the [Udap.Auth.Server](../../examples/Udap.Auth.Server/) example project. ```csharp - var builder = WebApplication.CreateBuilder(args); builder.Services.AddIdentityServer() @@ -28,7 +41,6 @@ builder.Services.AddIdentityServer() { options.ConfigureDbContext = b => b.UseSqlite(connectionString, dbOpts => dbOpts.MigrationsAssembly(migrationsAssembly)); - }) .AddResourceStore() .AddClientStore() @@ -39,8 +51,8 @@ builder.Services.AddIdentityServer() var udapServerOptions = builder.Configuration.GetOption("ServerSettings"); options.DefaultSystemScopes = udapServerOptions.DefaultSystemScopes; options.DefaultUserScopes = udapServerOptions.DefaultUserScopes; - options.ForceStateParamOnAuthorizationCode = udapServerOptions. - ForceStateParamOnAuthorizationCode; + options.ForceStateParamOnAuthorizationCode = udapServerOptions + .ForceStateParamOnAuthorizationCode; }, options => options.UdapDbContext = b => @@ -50,35 +62,35 @@ builder.Services.AddIdentityServer() baseUrl: "https://localhost:5002/connect/register" ); - var app = builder.Build(); +var app = builder.Build(); - // uncomment if you want to add a UI - app.UseStaticFiles(); - app.UseRouting(); +app.UseStaticFiles(); +app.UseRouting(); - app.UseUdapServer(); - app.UseIdentityServer(); +app.UseUdapServer(); +app.UseIdentityServer(); - // uncomment if you want to add a UI - app.UseAuthorization(); - app.MapRazorPages().RequireAuthorization(); - - app.Run; +app.UseAuthorization(); +app.MapRazorPages().RequireAuthorization(); +app.Run(); ``` -## Udap.Auth.Server Database Configuration +## Database Configuration -For your convenience a EF Migrations Project called [UdapDb.SqlServer](/migrations/UdapDb.SqlServer/) can deploy the database schema. Run from Visual Studio using the UdapDb profile (/properties/launchSettings.json). This project will create all the Udap tables and Duende Identity tables. It will seed data needed for running local system tests. See the SeedData.cs for details. +EF Core migration projects are available for both database providers: -If you need another database such as PostgreSQL I could be motivated to create one. +- [UdapDb.SqlServer](../../migrations/UdapDb.SqlServer/) — SQL Server migrations +- [UdapDb.Postgres](../../migrations/UdapDb.Postgres/) — PostgreSQL migrations -Not the [UdapDb.SqlServer](/migrations/UdapDb.SqlServer/) project includes two migrations for Duende's Identity Server tables. I have not put anytime into migrating a schema. At this point my pattern is to just delete the database and re-create it. At some point I will version this and start migrating officially. +These projects create all UDAP and Duende IdentityServer tables and seed data needed for running local tests. See `SeedData.cs` for details. -## UDAP Authorization Server Examples +## Examples -- [Udap.Auth.Server](./../examples/Udap.Auth.Server/) +- [Udap.Auth.Server](../../examples/Udap.Auth.Server/) - [Udap.Auth.Server Deployed](https://securedcontrols.net/.well-known/udap) +--- + - FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7. -- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. UDAP Draft Specifications are referenced and displayed in parts of this source code to document specification implementation. +- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. diff --git a/Udap.Smart.Metadata/docs/README.md b/Udap.Smart.Metadata/docs/README.md index 93510b81c..0c04eab91 100644 --- a/Udap.Smart.Metadata/docs/README.md +++ b/Udap.Smart.Metadata/docs/README.md @@ -2,6 +2,6 @@ ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package - +## 📦 Nuget Package: [Udap.Smart.Metadata](https://www.nuget.org/packages/Udap.Smart.Metadata) +Server-side implementation of the `.well-known/smart-configuration` endpoint per the [HL7 FHIR SMART App Launch v2.1](http://hl7.org/fhir/smart-app-launch/) specification. Returns SMART metadata including OAuth endpoints, scopes, capabilities, and authentication methods. diff --git a/Udap.Smart.Model/docs/README.md b/Udap.Smart.Model/docs/README.md index 6875461af..c15c4300a 100644 --- a/Udap.Smart.Model/docs/README.md +++ b/Udap.Smart.Model/docs/README.md @@ -2,6 +2,6 @@ ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package - +## 📦 Nuget Package: [Udap.Smart.Model](https://www.nuget.org/packages/Udap.Smart.Model) +Data models for FHIR SMART App Launch metadata configuration based on the [HL7 FHIR SMART App Launch v2.1](http://hl7.org/fhir/smart-app-launch/) specification. Includes the `SmartMetadata` class defining authorization endpoints, token endpoints, grant types, scopes, and capabilities. diff --git a/Udap.Ssraa.Server/ServiceCollectionExtensions.cs b/Udap.Ssraa.Server/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..1e56c96a4 --- /dev/null +++ b/Udap.Ssraa.Server/ServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.DependencyInjection; +using Udap.Server.Validation; +using Udap.Ssraa.Server; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +public static class SsraaServiceCollectionExtensions +{ + /// + /// Registers the SSRAA community token validator with default options. + /// + public static IServiceCollection AddUdapSsraaValidation( + this IServiceCollection services) + { + return services.AddUdapSsraaValidation(_ => { }); + } + + /// + /// Registers the SSRAA community token validator with configurable options. + /// Use the configure action to add community names and customize required extensions. + /// + public static IServiceCollection AddUdapSsraaValidation( + this IServiceCollection services, + Action configure) + { + services.Configure(configure); + services.AddSingleton(); + + return services; + } +} diff --git a/Udap.Ssraa.Server/SsraaTokenValidator.cs b/Udap.Ssraa.Server/SsraaTokenValidator.cs new file mode 100644 index 000000000..31b050404 --- /dev/null +++ b/Udap.Ssraa.Server/SsraaTokenValidator.cs @@ -0,0 +1,137 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Options; +using Udap.Server.Validation; + +namespace Udap.Ssraa.Server; + +/// +/// Validates standard SSRAA community token request rules: +/// +/// 1. Enforces required extensions per grant type (configurable via ). +/// 2. Validates purpose_of_use codes against the HL7 v3 PurposeOfUse value set +/// (OID: 2.16.840.1.113883.5.8). +/// +/// +/// HL7 v3 PurposeOfUse Value Set +/// +public class SsraaTokenValidator : ICommunityTokenValidator +{ + private readonly SsraaValidationOptions _options; + + /// + /// HL7 v3 PurposeOfUse value set (OID: 2.16.840.1.113883.5.8). + /// + /// HL7 v3 PurposeOfUse Value Set + /// + internal static readonly HashSet AllHl7V3PurposeOfUseCodes = new(StringComparer.Ordinal) + { + "urn:oid:2.16.840.1.113883.5.8#TREAT", + "urn:oid:2.16.840.1.113883.5.8#ETREAT", + "urn:oid:2.16.840.1.113883.5.8#BTG", + "urn:oid:2.16.840.1.113883.5.8#ERTREAT", + "urn:oid:2.16.840.1.113883.5.8#COC", + "urn:oid:2.16.840.1.113883.5.8#CLINTRL", + "urn:oid:2.16.840.1.113883.5.8#POPHLTH", + "urn:oid:2.16.840.1.113883.5.8#TREATDS", + "urn:oid:2.16.840.1.113883.5.8#HPAYMT", + "urn:oid:2.16.840.1.113883.5.8#CLMATTCH", + "urn:oid:2.16.840.1.113883.5.8#COVAUTH", + "urn:oid:2.16.840.1.113883.5.8#COVERAGE", + "urn:oid:2.16.840.1.113883.5.8#ELIGDTRM", + "urn:oid:2.16.840.1.113883.5.8#ELIGVER", + "urn:oid:2.16.840.1.113883.5.8#ENROLLM", + "urn:oid:2.16.840.1.113883.5.8#MILDCRG", + "urn:oid:2.16.840.1.113883.5.8#REMITADV", + "urn:oid:2.16.840.1.113883.5.8#PMTDS", + "urn:oid:2.16.840.1.113883.5.8#HOPERAT", + "urn:oid:2.16.840.1.113883.5.8#CAREMGT", + "urn:oid:2.16.840.1.113883.5.8#DONAT", + "urn:oid:2.16.840.1.113883.5.8#FRAUD", + "urn:oid:2.16.840.1.113883.5.8#GOV", + "urn:oid:2.16.840.1.113883.5.8#HACCRED", + "urn:oid:2.16.840.1.113883.5.8#HCOMPL", + "urn:oid:2.16.840.1.113883.5.8#HDECD", + "urn:oid:2.16.840.1.113883.5.8#HDIRECT", + "urn:oid:2.16.840.1.113883.5.8#HDM", + "urn:oid:2.16.840.1.113883.5.8#HLEGAL", + "urn:oid:2.16.840.1.113883.5.8#HOUTCOMS", + "urn:oid:2.16.840.1.113883.5.8#HPRGRP", + "urn:oid:2.16.840.1.113883.5.8#HQUALIMP", + "urn:oid:2.16.840.1.113883.5.8#HSYSADMIN", + "urn:oid:2.16.840.1.113883.5.8#LABELING", + "urn:oid:2.16.840.1.113883.5.8#METAMGT", + "urn:oid:2.16.840.1.113883.5.8#MEMADMIN", + "urn:oid:2.16.840.1.113883.5.8#MILCDM", + "urn:oid:2.16.840.1.113883.5.8#PATADMIN", + "urn:oid:2.16.840.1.113883.5.8#PATSFTY", + "urn:oid:2.16.840.1.113883.5.8#PERFMSR", + "urn:oid:2.16.840.1.113883.5.8#RECORDMGT", + "urn:oid:2.16.840.1.113883.5.8#SYSDEV", + "urn:oid:2.16.840.1.113883.5.8#HTEST", + "urn:oid:2.16.840.1.113883.5.8#TRAIN", + "urn:oid:2.16.840.1.113883.5.8#MLTRAINING", + "urn:oid:2.16.840.1.113883.5.8#HRESCH", + "urn:oid:2.16.840.1.113883.5.8#BIORCH", + "urn:oid:2.16.840.1.113883.5.8#CLINTRCH", + "urn:oid:2.16.840.1.113883.5.8#CLINTRCHNPC", + "urn:oid:2.16.840.1.113883.5.8#CLINTRCHPC", + "urn:oid:2.16.840.1.113883.5.8#PRECLINTRCH", + "urn:oid:2.16.840.1.113883.5.8#DSRCH", + "urn:oid:2.16.840.1.113883.5.8#POARCH", + "urn:oid:2.16.840.1.113883.5.8#TRANSRCH", + "urn:oid:2.16.840.1.113883.5.8#PATRQT", + "urn:oid:2.16.840.1.113883.5.8#FAMRQT", + "urn:oid:2.16.840.1.113883.5.8#PWATRNY", + "urn:oid:2.16.840.1.113883.5.8#SUPNWK", + "urn:oid:2.16.840.1.113883.5.8#PUBHLTH", + "urn:oid:2.16.840.1.113883.5.8#DISASTER", + "urn:oid:2.16.840.1.113883.5.8#THREAT", + "urn:oid:2.16.840.1.113883.5.8#HMARKT", + }; + + public SsraaTokenValidator(IOptions options) + { + _options = options.Value; + } + + /// + public bool AppliesToCommunity(string communityName) + => _options.Communities.Contains(communityName); + + /// + /// Returns SSRAA validation rules for the given grant type. + /// Required extensions are configurable via . + /// AllowedPurposeOfUse is the full HL7 v3 PurposeOfUse value set. + /// + public CommunityValidationRules? GetValidationRules(string? grantType) + { + var requiredExtensions = grantType switch + { + "client_credentials" => _options.ClientCredentialsExtensionsRequired, + "authorization_code" => _options.AuthorizationCodeExtensionsRequired, + _ => null + }; + + return new CommunityValidationRules + { + RequiredExtensions = requiredExtensions, + AllowedPurposeOfUse = AllHl7V3PurposeOfUseCodes, + MaxPurposeOfUseCount = null // SSRAA does not limit purpose_of_use count + }; + } + + /// + public Task ValidateAsync( + UdapAuthorizationExtensionValidationContext context) + { + return Task.FromResult(AuthorizationExtensionValidationResult.Success()); + } +} diff --git a/Udap.Ssraa.Server/SsraaValidationOptions.cs b/Udap.Ssraa.Server/SsraaValidationOptions.cs new file mode 100644 index 000000000..d78d6711c --- /dev/null +++ b/Udap.Ssraa.Server/SsraaValidationOptions.cs @@ -0,0 +1,35 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Udap.Ssraa.Server; + +/// +/// Configuration options for the SSRAA community token validator. +/// Maps community names to the SSRAA validation pipeline. +/// +public class SsraaValidationOptions +{ + /// + /// Community names that should use SSRAA validation rules. + /// + public HashSet Communities { get; set; } = new(StringComparer.Ordinal); + + /// + /// Extension keys required for client_credentials token requests (e.g., ["hl7-b2b"]). + /// Default: hl7-b2b required. + /// + public HashSet? ClientCredentialsExtensionsRequired { get; set; } = + [Udap.Model.UdapConstants.UdapAuthorizationExtensions.Hl7B2B]; + + /// + /// Extension keys required for authorization_code token requests. + /// Default: none required. + /// + public HashSet? AuthorizationCodeExtensionsRequired { get; set; } +} diff --git a/Udap.Ssraa.Server/Udap.Ssraa.Server.csproj b/Udap.Ssraa.Server/Udap.Ssraa.Server.csproj new file mode 100644 index 000000000..bcc2e4c95 --- /dev/null +++ b/Udap.Ssraa.Server/Udap.Ssraa.Server.csproj @@ -0,0 +1,44 @@ + + + + net8.0;net9.0;net10.0 + enable + latest + enable + + git + https://github.com/JoeShook/udap-dotnet + README.md + Joseph Shook + Copyright © Joseph.Shook@Surescripts.com 2026 + MIT + true + UDAP_Ecosystem_Gears 48X48.jpg + UDAP;FHIR;HL7;SSRAA + SSRAA community-specific validators for UDAP token issuance with HL7 v3 PurposeOfUse enforcement. + + + + + \ + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Udap.Ssraa.Server/docs/README.md b/Udap.Ssraa.Server/docs/README.md new file mode 100644 index 000000000..11fd4dfa0 --- /dev/null +++ b/Udap.Ssraa.Server/docs/README.md @@ -0,0 +1,3 @@ +## Udap.Ssraa.Server + +SSRAA community-specific validators for UDAP token issuance with HL7 v3 PurposeOfUse enforcement. diff --git a/Udap.Tefca.Model/TEFCAAuthorizationErrorExtension.cs b/Udap.Tefca.Model/TEFCAAuthorizationErrorExtension.cs new file mode 100644 index 000000000..c30d271bb --- /dev/null +++ b/Udap.Tefca.Model/TEFCAAuthorizationErrorExtension.cs @@ -0,0 +1,46 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Udap.Tefca.Model; + +/// +/// TEFCA Authorization Extension Error Object returned in the "extensions" object +/// of an invalid_grant error response when the data holder determines that the +/// authorization metadata submitted is insufficient because the requestor has +/// omitted the ACP parameter or has asserted a policy that is not acceptable. +/// +/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 B2B #3, Table 1 +/// +public class TEFCAAuthorizationErrorExtension +{ + /// + /// consent_required (required): + /// The list of acceptable Access Consent Policy Identifier(s) + /// corresponding to the asserted Access Policy required for + /// authorization, an array of string values from the list of valid policy + /// OIDs each expressed as a URI. + /// + /// SOP v2.0 — Table 1 + /// + [JsonPropertyName("consent_required")] + public ICollection ConsentRequired { get; set; } = []; + + /// + /// consent_form (optional): + /// A URL as a string where the required consent form may be + /// downloaded, if applicable. + /// + /// SOP v2.0 — Table 1 + /// + [JsonPropertyName("consent_form")] + public string? ConsentForm { get; set; } +} diff --git a/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs b/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs index d58e88bfd..c176193fc 100644 --- a/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs +++ b/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs @@ -17,18 +17,16 @@ namespace Udap.Tefca.Model; /// /// TEFCA IAS Authorization Extension Object. /// -/// TEFCA IAS AUTHORIZATION EXTENSION OBJECT +/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 IAS, Table 4 /// public class TEFCAIASAuthorizationExtension : IAuthorizationExtensionObject { private string _version = "1"; private JsonElement? _userInformation; private JsonElement? _patientInformation; - private string _purposeOfUse = TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUseCode; private ICollection? _consentPolicy; private ICollection? _consentReference; private JsonElement? _idToken; - private JsonElement? _ialVetted; public TEFCAIASAuthorizationExtension() { @@ -49,18 +47,6 @@ public string Version set => _version = value; } - /// - /// purpose_of_use required: - /// - /// Fixed Value "T-IAS". - /// - [JsonPropertyName(TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUse)] - public string PurposeOfUse - { - get => _purposeOfUse; - set => _purposeOfUse = value; - } - /// /// user_information required: /// @@ -119,9 +105,13 @@ public ICollection? ConsentReference } /// - /// id_token optional: + /// id_token required: /// - /// Additional token as per relevant SOP + /// The CSP-provided OpenID Connect token as further defined in + /// the Exchange Purpose (XP) Implementation SOP: Individual Access Services (IAS). + /// Responding server SHOULD respond with invalid_grant if missing. + /// + /// SOP v2.0 — Table 4 /// [JsonPropertyName(TefcaConstants.TEFCAIASAuthorizationExtension.IdToken)] public JsonElement? IdToken @@ -130,19 +120,6 @@ public JsonElement? IdToken set => _idToken = value; } - /// - /// ial_vetted conditional: - /// - /// OIDC token provided by Identity Verifier when the Identity Verifier is not - /// the Responding Node. Responding server MAY respond with invalid_grant if missing. - /// - [JsonPropertyName(TefcaConstants.TEFCAIASAuthorizationExtension.IalVetted)] - public JsonElement? IalVetted - { - get => _ialVetted; - set => _ialVetted = value; - } - /// public List Validate() { @@ -163,11 +140,6 @@ public List Validate() notes.Add($"Missing required {TefcaConstants.TEFCAIASAuthorizationExtension.PatientInformation}"); } - if (PurposeOfUse != TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUseCode) - { - notes.Add($"{TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUse} must be {TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUseCode}"); - } - return notes; } diff --git a/Udap.Tefca.Model/TefcaCertificationDocument.cs b/Udap.Tefca.Model/TefcaCertificationDocument.cs new file mode 100644 index 000000000..605cc29fe --- /dev/null +++ b/Udap.Tefca.Model/TefcaCertificationDocument.cs @@ -0,0 +1,54 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Udap.Model.Registration; + +namespace Udap.Tefca.Model; + +/// +/// TEFCA-specific certification document extending the base UDAP certification +/// with claims required by the Facilitated FHIR Implementation SOP. +/// +/// The basic-app-certification JWT MUST contain: +/// - certification_name: "TEFCA Basic App Certification" +/// - certification_uris: ["https://rce.sequoiaproject.org/udap/profiles/basic-app-certification"] +/// - exchange_purposes: Array of one Exchange Purpose from the TEFCA Exchange Purposes SOP +/// - home_community_id: The HomeCommunityId of the Node making the registration request +/// +/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 Registration #6 +/// +public class TefcaCertificationDocument : UdapCertificationAndEndorsementDocument +{ + public TefcaCertificationDocument() + : base(TefcaConstants.Certification.BasicAppCertificationName) + { + CertificationUris = [TefcaConstants.Certification.BasicAppCertificationUri]; + } + + /// + /// An array of one Exchange Purpose from the TEFCA Exchange Purposes SOP. + /// + [JsonPropertyName(TefcaConstants.Certification.ExchangePurposes)] + public ICollection? ExchangePurposes { get; set; } + + /// + /// The HomeCommunityId of the Node making the registration request. + /// + [JsonPropertyName(TefcaConstants.Certification.HomeCommunityId)] + public string? HomeCommunityId { get; set; } + + /// + public override string SerializeToJson() + { + return JsonSerializer.Serialize(this); + } +} diff --git a/Udap.Tefca.Model/TefcaConstants.cs b/Udap.Tefca.Model/TefcaConstants.cs index 570fdbe29..60e23f48f 100644 --- a/Udap.Tefca.Model/TefcaConstants.cs +++ b/Udap.Tefca.Model/TefcaConstants.cs @@ -11,7 +11,8 @@ namespace Udap.Tefca.Model; /// /// Constants specific to the TEFCA trust community profile. -/// See SOP: Facilitated FHIR Implementation and SOP: Exchange Purposes (XPs). +/// +/// SOP: Facilitated FHIR Implementation v2.0 /// public static class TefcaConstants { @@ -19,6 +20,8 @@ public static class UdapAuthorizationExtensions { /// /// TEFCA IAS Authorization Extension Object key name. + /// + /// SOP v2.0 — Section 6.11 IAS, Table 4 /// public const string TEFCAIAS = "tefca-ias"; @@ -28,16 +31,24 @@ public static class UdapAuthorizationExtensions public const string TEFCASMART = "tefca_smart"; } + /// + /// Field name constants for the TEFCA IAS Authorization Extension Object. + /// + /// SOP v2.0 — Section 6.11 IAS, Table 4 + /// public static class TEFCAIASAuthorizationExtension { + /// Fixed string value: "1". Table 4 public const string Version = "version"; - public const string PurposeOfUse = "purpose_of_use"; - public const string PurposeOfUseCode = "T-IAS"; + /// FHIR RelatedPerson Resource with all known demographics. Table 4 public const string UserInformation = "user_information"; + /// FHIR US Core Patient Resource with all known demographics. Table 4 public const string PatientInformation = "patient_information"; - public const string IalVetted = "ial_vetted"; + /// Access Consent Policy Identifier for identity proofing level of assurance. Table 4 public const string ConsentPolicy = "consent_policy"; + /// Array of FHIR DocumentReference or Consent resource URLs. Table 4 public const string ConsentReference = "consent_reference"; + /// The CSP-provided OpenID Connect token. Table 4 public const string IdToken = "id_token"; } @@ -52,6 +63,8 @@ public static class TEFCASMARTAuthorizationExtension /// /// TEFCA Exchange Purpose (XP) codes OID: 2.16.840.1.113883.3.7204.1.5.2.1 + /// + /// SOP: Exchange Purposes (XPs) v4.0 — Table 1 /// public static class ExchangePurposeCodes { @@ -73,13 +86,18 @@ public static class ExchangePurposeCodes /// /// TEFCA certification constants. - /// See SOP: Facilitated FHIR Implementation Section 6.11 Registration #6. + /// + /// SOP v2.0 — Section 6.11 Registration #6 /// public static class Certification { + /// URI identifying the TEFCA Basic App Certification program. Section 6.11 #2, #3 public const string BasicAppCertificationUri = "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification"; + /// Fixed certification_name value. Section 6.11 Registration #6 public const string BasicAppCertificationName = "TEFCA Basic App Certification"; + /// Array of one Exchange Purpose from the TEFCA Exchange Purposes SOP. Section 6.11 Registration #6 public const string ExchangePurposes = "exchange_purposes"; + /// HomeCommunityId of the Node making the registration request. Section 6.11 Registration #6 public const string HomeCommunityId = "home_community_id"; } diff --git a/Udap.Tefca.Server/ServiceCollectionExtensions.cs b/Udap.Tefca.Server/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..ea96b6e0a --- /dev/null +++ b/Udap.Tefca.Server/ServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.DependencyInjection; +using Udap.Server.Registration; +using Udap.Server.Validation; + +namespace Udap.Tefca.Server; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers TEFCA community-specific validators for registration and token issuance + /// using default options (applies to ). + /// Call after AddUdapServerConfiguration() and AddUdapJwtBearerClientAuthentication(). + /// + public static IServiceCollection AddUdapTefcaValidation(this IServiceCollection services) + { + return services.AddUdapTefcaValidation(_ => { }); + } + + /// + /// Registers TEFCA community-specific validators for registration and token issuance. + /// Use the configure action to map additional community names to the TEFCA validation pipeline. + /// Call after AddUdapServerConfiguration() and AddUdapJwtBearerClientAuthentication(). + /// + /// + /// + /// builder.Services.AddUdapTefcaValidation(options => + /// { + /// options.Communities.Add("tefca://test-community"); + /// }); + /// + /// + public static IServiceCollection AddUdapTefcaValidation( + this IServiceCollection services, + Action configure) + { + services.Configure(configure); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/Udap.Tefca.Server/TefcaRegistrationValidator.cs b/Udap.Tefca.Server/TefcaRegistrationValidator.cs new file mode 100644 index 000000000..2cc673906 --- /dev/null +++ b/Udap.Tefca.Server/TefcaRegistrationValidator.cs @@ -0,0 +1,88 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Options; +using Udap.Model.Registration; +using Udap.Server.Registration; +using Udap.Tefca.Model; + +namespace Udap.Tefca.Server; + +/// +/// Validates that a TEFCA community registration uses a SAN URI containing +/// a valid Exchange Purpose (XP) code from the TEFCA Exchange Purposes SOP. +/// +/// +/// SOP v2.0 — Section 6.11 Registration #5a +/// +public class TefcaRegistrationValidator : ICommunityRegistrationValidator +{ + private readonly TefcaValidationOptions _options; + + public TefcaRegistrationValidator(IOptions options) + { + _options = options.Value; + } + + private static readonly HashSet ValidExchangePurposes = new(StringComparer.Ordinal) + { + TefcaConstants.ExchangePurposeCodes.Treatment, + TefcaConstants.ExchangePurposeCodes.TefcaRequiredTreatment, + TefcaConstants.ExchangePurposeCodes.Payment, + TefcaConstants.ExchangePurposeCodes.HealthCareOperations, + TefcaConstants.ExchangePurposeCodes.CareCoordination, + TefcaConstants.ExchangePurposeCodes.HedisReporting, + TefcaConstants.ExchangePurposeCodes.QualityMeasureReporting, + TefcaConstants.ExchangePurposeCodes.PublicHealth, + TefcaConstants.ExchangePurposeCodes.ElectronicCaseReporting, + TefcaConstants.ExchangePurposeCodes.ElectronicLabReporting, + TefcaConstants.ExchangePurposeCodes.IndividualAccessServices, + TefcaConstants.ExchangePurposeCodes.GovernmentBenefitsDetermination, + }; + + /// + public bool AppliesToCommunity(string communityName) + => _options.Communities.Contains(communityName); + + /// + public Task ValidateAsync( + UdapDynamicClientRegistrationContext context) + { + var issuer = context.Issuer; + + if (string.IsNullOrEmpty(issuer)) + { + return Task.FromResult( + new UdapDynamicClientRegistrationValidationResult( + UdapDynamicClientRegistrationErrors.InvalidSoftwareStatement, + "TEFCA registration requires a SAN URI with an exchange purpose")); + } + + var hashIndex = issuer.LastIndexOf('#'); + if (hashIndex < 0 || hashIndex == issuer.Length - 1) + { + return Task.FromResult( + new UdapDynamicClientRegistrationValidationResult( + UdapDynamicClientRegistrationErrors.InvalidSoftwareStatement, + "TEFCA SAN URI must contain an exchange purpose code after '#'")); + } + + var xpCode = issuer.Substring(hashIndex + 1); + + if (!ValidExchangePurposes.Contains(xpCode)) + { + return Task.FromResult( + new UdapDynamicClientRegistrationValidationResult( + UdapDynamicClientRegistrationErrors.InvalidSoftwareStatement, + $"TEFCA SAN URI contains invalid exchange purpose code '{xpCode}'")); + } + + return Task.FromResult(null); + } +} diff --git a/Udap.Tefca.Server/TefcaTokenValidator.cs b/Udap.Tefca.Server/TefcaTokenValidator.cs new file mode 100644 index 000000000..d79dc49af --- /dev/null +++ b/Udap.Tefca.Server/TefcaTokenValidator.cs @@ -0,0 +1,162 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Options; +using Udap.Model; +using Udap.Model.UdapAuthenticationExtensions; +using Udap.Server.Validation; +using Udap.Tefca.Model; + +namespace Udap.Tefca.Server; + +/// +/// Validates TEFCA-specific token request rules per SOP v2.0 Section 6.11: +/// +/// 1. Declares required extensions per grant type (hl7-b2b for client_credentials, +/// none for authorization_code). +/// 2. Enforces allowed purpose_of_use codes from the TEFCA Exchange Purposes SOP v4.0. +/// 3. Enforces max 1 purpose_of_use entry per Table 2 ("A length 1 array"). +/// 4. The purpose_of_use in the hl7-b2b AEO must match the exchange purpose +/// coded in the client's registered SAN URI. +/// 5. If the registered exchange purpose is T-IAS and the grant type is +/// client_credentials, the tefca_ias AEO must be present. +/// +/// +/// SOP v2.0 — Table 2 and IAS Queries +/// +public class TefcaTokenValidator : ICommunityTokenValidator +{ + private readonly TefcaValidationOptions _options; + + /// + /// All 12 TEFCA Exchange Purpose codes in full OID URI format. + /// + /// SOP: Exchange Purposes (XPs) v4.0 — Table 1 + /// + internal static readonly HashSet AllTefcaXpCodes = new(StringComparer.Ordinal) + { + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.Treatment}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.TefcaRequiredTreatment}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.Payment}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.HealthCareOperations}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.CareCoordination}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.HedisReporting}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.QualityMeasureReporting}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.PublicHealth}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.ElectronicCaseReporting}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.ElectronicLabReporting}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.IndividualAccessServices}", + $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.GovernmentBenefitsDetermination}", + }; + + public TefcaTokenValidator(IOptions options) + { + _options = options.Value; + } + + /// + public bool AppliesToCommunity(string communityName) + => _options.Communities.Contains(communityName); + + /// + /// Returns TEFCA SOP v2.0 validation rules for the given grant type. + /// + /// client_credentials: hl7-b2b required, max 1 purpose_of_use from TEFCA XP codes + /// authorization_code: no extensions required (per spec), same POU rules if extensions are present + /// + /// + public CommunityValidationRules? GetValidationRules(string? grantType) + { + var requiredExtensions = grantType switch + { + "client_credentials" => new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + "authorization_code" => [], + _ => null + }; + + return new CommunityValidationRules + { + RequiredExtensions = requiredExtensions, + AllowedPurposeOfUse = AllTefcaXpCodes, + MaxPurposeOfUseCount = 1 + }; + } + + /// + public Task ValidateAsync( + UdapAuthorizationExtensionValidationContext context) + { + if (string.IsNullOrEmpty(context.SanUri)) + { + return Task.FromResult(AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + "TEFCA client has no registered SAN URI")); + } + + var hashIndex = context.SanUri.LastIndexOf('#'); + if (hashIndex < 0 || hashIndex == context.SanUri.Length - 1) + { + return Task.FromResult(AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + "TEFCA client's registered SAN URI does not contain an exchange purpose")); + } + + var registeredXp = context.SanUri.Substring(hashIndex + 1); + + // IAS + client_credentials requires tefca_ias AEO (SOP v2.0 Section 6.11, IAS Queries #3) + if (string.Equals(registeredXp, TefcaConstants.ExchangePurposeCodes.IndividualAccessServices, StringComparison.Ordinal) + && string.Equals(context.GrantType, "client_credentials", StringComparison.OrdinalIgnoreCase)) + { + if (context.Extensions == null + || !context.Extensions.ContainsKey(TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS)) + { + return Task.FromResult(AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + "TEFCA IAS client_credentials token request requires the 'tefca_ias' authorization extension")); + } + } + + if (context.Extensions == null || context.Extensions.Count == 0) + { + return Task.FromResult(AuthorizationExtensionValidationResult.Success()); + } + + foreach (var (key, value) in context.Extensions) + { + if (value is IAuthorizationExtensionObject extObj) + { + var purposeOfUse = extObj.GetPurposeOfUse(); + if (purposeOfUse == null || purposeOfUse.Count == 0) + { + continue; + } + + foreach (var code in purposeOfUse) + { + // Extract XP code from full URI format: urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT + var pouCode = code; + var pouHashIndex = code.LastIndexOf('#'); + if (pouHashIndex >= 0 && pouHashIndex < code.Length - 1) + { + pouCode = code.Substring(pouHashIndex + 1); + } + + if (!string.Equals(pouCode, registeredXp, StringComparison.Ordinal)) + { + return Task.FromResult(AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + $"purpose_of_use '{pouCode}' does not match registered exchange purpose '{registeredXp}'")); + } + } + } + } + + return Task.FromResult(AuthorizationExtensionValidationResult.Success()); + } +} diff --git a/Udap.Tefca.Server/TefcaValidationOptions.cs b/Udap.Tefca.Server/TefcaValidationOptions.cs new file mode 100644 index 000000000..3df8cec9e --- /dev/null +++ b/Udap.Tefca.Server/TefcaValidationOptions.cs @@ -0,0 +1,28 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Udap.Tefca.Server; + +/// +/// Configuration options for TEFCA community validators. +/// Maps community names to the TEFCA validation pipeline, +/// allowing the same validators to apply to multiple communities +/// regardless of their naming convention. +/// +public class TefcaValidationOptions +{ + /// + /// Community names that should use TEFCA validation rules. + /// Defaults to . + /// + public HashSet Communities { get; set; } = new(StringComparer.Ordinal) + { + Udap.Tefca.Model.TefcaConstants.CommunityUri + }; +} diff --git a/Udap.Tefca.Server/Udap.Tefca.Server.csproj b/Udap.Tefca.Server/Udap.Tefca.Server.csproj new file mode 100644 index 000000000..083051217 --- /dev/null +++ b/Udap.Tefca.Server/Udap.Tefca.Server.csproj @@ -0,0 +1,45 @@ + + + + net8.0;net9.0;net10.0 + enable + latest + enable + + git + https://github.com/JoeShook/udap-dotnet + README.md + Joseph Shook + Copyright © Joseph.Shook@Surescripts.com 2026 + MIT + true + UDAP_Ecosystem_Gears 48X48.jpg + UDAP;FHIR;HL7;TEFCA + TEFCA community-specific validators for UDAP registration and token issuance. + + + + + \ + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Udap.Tefca.Server/docs/README.md b/Udap.Tefca.Server/docs/README.md new file mode 100644 index 000000000..8bca1d2d9 --- /dev/null +++ b/Udap.Tefca.Server/docs/README.md @@ -0,0 +1,7 @@ +# Udap.Tefca.Server + +TEFCA community-specific validators for UDAP registration and token issuance. + +Implements `ICommunityRegistrationValidator` and `ICommunityTokenValidator` to enforce +TEFCA SOP: Facilitated FHIR Implementation v2.0 requirements for exchange purpose +validation during client registration and token requests. diff --git a/Udap.UI/docs/README.md b/Udap.UI/docs/README.md index 04b4a14d0..76a1cd9ae 100644 --- a/Udap.UI/docs/README.md +++ b/Udap.UI/docs/README.md @@ -1,7 +1,9 @@ -# Udap.Util +# Udap.UI ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package +## 📦 Nuget Package: [Udap.UI](https://www.nuget.org/packages/Udap.UI) -Include this package in server that already includes the Udap.Server package. It adds razor pages needed for Duende IdentityServer and a few extra pages for UDAP Tiered OAuth. +This package provides Razor Pages and static assets for the authentication and consent UI used with Duende IdentityServer and UDAP. It includes pages for login, logout, consent, device authorization, CIBA, and grant management, along with support for UDAP Tiered OAuth flows. + +Include this package in a server that already includes the `Udap.Server` package. diff --git a/Udap.sln b/Udap.sln index d7f475142..1d154d49d 100644 --- a/Udap.sln +++ b/Udap.sln @@ -56,10 +56,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.Model", "Udap.Model\Ud EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.Util", "Udap.Util\Udap.Util.csproj", "{B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.CA", "examples\Udap.CA\Udap.CA.csproj", "{A634BD23-B31B-4260-922E-24FEC8B4CF22}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.CA.Tests", "_tests\Udap.CA.Tests\Udap.CA.Tests.csproj", "{9191CC9B-4DD0-4115-8FFD-A710659DBC27}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.Client.System.Tests", "_tests\Udap.Client.System.Tests\Udap.Client.System.Tests.csproj", "{63DEA604-5D81-4C3A-AF43-0A296372EB20}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UdapMetadata.Tests", "_tests\UdapMetadata.Tests\UdapMetadata.Tests.csproj", "{872BDB24-9C83-4089-A5ED-086CAC6103B1}" @@ -104,6 +100,49 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Server.Storage", "Udap EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Tefca.Model", "Udap.Tefca.Model\Udap.Tefca.Model.csproj", "{0DBA6694-00CA-49DA-96D8-A71697BAFCBB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Tefca.Server", "Udap.Tefca.Server\Udap.Tefca.Server.csproj", "{6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Ssraa.Server", "Udap.Ssraa.Server\Udap.Ssraa.Server.csproj", "{0E818FA7-3670-4E89-B4C5-656DD7E7FB73}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{A5033865-E5C6-4D6D-9101-2A3D2C2802BE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "specifications", "specifications", "{BC60574D-16B6-45FA-8304-9B1C8C4B461D}" + ProjectSection(SolutionItems) = preProject + docs\specifications\QTF-2.1-Draft-12.04.25-clean1.pdf = docs\specifications\QTF-2.1-Draft-12.04.25-clean1.pdf + docs\specifications\SOP-Directory-Requirements_508-1.pdf = docs\specifications\SOP-Directory-Requirements_508-1.pdf + docs\specifications\SOP-Exchange-Purposes_CA-v2_v4-508.pdf = docs\specifications\SOP-Exchange-Purposes_CA-v2_v4-508.pdf + docs\specifications\SOP-Facilitated-FHIR-Implementation-2.0-Draft-508.pdf = docs\specifications\SOP-Facilitated-FHIR-Implementation-2.0-Draft-508.pdf + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CA", "CA", "{0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}" + ProjectSection(SolutionItems) = preProject + examples\CA\README.md = examples\CA\README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Common", "examples\CA\Sigil.Common\Sigil.Common.csproj", "{3805EA18-B451-063D-B22C-8342113DBFBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.UI", "examples\CA\Sigil.UI\Sigil.UI.csproj", "{0AB89BB3-8C37-5C1C-66DC-348052B379A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil", "examples\CA\Sigil\Sigil.csproj", "{6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.AppHost", "examples\CA\Sigil.AppHost\Sigil.AppHost.csproj", "{860FEFA9-6FD6-5F81-A3ED-65B532691C8F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.ServiceDefaults", "examples\CA\Sigil.ServiceDefaults\Sigil.ServiceDefaults.csproj", "{239D5685-73B4-3B87-1C71-8B5F86A87BE5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Vault.Hosting", "examples\CA\Sigil.Vault.Hosting\Sigil.Vault.Hosting.csproj", "{B7B2B726-59D4-EE71-3853-DE2942846E49}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_tests", "_tests", "{02AA470A-D838-4674-8373-D81C848E5138}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Signing.Tests", "examples\CA\_tests\Sigil.Signing.Tests\Sigil.Signing.Tests.csproj", "{7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Vault", "examples\CA\Sigil.Vault\Sigil.Vault.csproj", "{754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Gcp", "examples\CA\Sigil.Gcp\Sigil.Gcp.csproj", "{927A9F26-8854-4AD1-15B3-4669C0842D88}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.FileSystem", "examples\CA\Sigil.FileSystem\Sigil.FileSystem.csproj", "{1AAB0311-6318-870E-2F18-9F71A52C7B31}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Certificate.Server", "examples\CA\Sigil.Certificate.Server\Sigil.Certificate.Server.csproj", "{EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -333,42 +372,6 @@ Global {B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}.Release|x64.Build.0 = Release|Any CPU {B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}.Release|x86.ActiveCfg = Release|Any CPU {B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}.Release|x86.Build.0 = Release|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x64.ActiveCfg = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x64.Build.0 = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x86.ActiveCfg = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x86.Build.0 = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|Any CPU.Build.0 = Debug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x64.ActiveCfg = FullDebug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x64.Build.0 = FullDebug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x86.ActiveCfg = FullDebug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x86.Build.0 = FullDebug|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|Any CPU.Build.0 = Release|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x64.ActiveCfg = Release|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x64.Build.0 = Release|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x86.ActiveCfg = Release|Any CPU - {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x86.Build.0 = Release|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x64.ActiveCfg = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x64.Build.0 = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x86.ActiveCfg = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x86.Build.0 = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|Any CPU.Build.0 = Debug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x64.ActiveCfg = FullDebug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x64.Build.0 = FullDebug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x86.ActiveCfg = FullDebug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x86.Build.0 = FullDebug|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|Any CPU.Build.0 = Release|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x64.ActiveCfg = Release|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x64.Build.0 = Release|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x86.ActiveCfg = Release|Any CPU - {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x86.Build.0 = Release|Any CPU {63DEA604-5D81-4C3A-AF43-0A296372EB20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {63DEA604-5D81-4C3A-AF43-0A296372EB20}.Debug|Any CPU.Build.0 = Debug|Any CPU {63DEA604-5D81-4C3A-AF43-0A296372EB20}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -765,6 +768,240 @@ Global {0DBA6694-00CA-49DA-96D8-A71697BAFCBB}.Release|x64.Build.0 = Release|Any CPU {0DBA6694-00CA-49DA-96D8-A71697BAFCBB}.Release|x86.ActiveCfg = Release|Any CPU {0DBA6694-00CA-49DA-96D8-A71697BAFCBB}.Release|x86.Build.0 = Release|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x64.Build.0 = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x86.Build.0 = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x64.Build.0 = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x86.Build.0 = Debug|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|Any CPU.Build.0 = Release|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x64.ActiveCfg = Release|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x64.Build.0 = Release|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x86.ActiveCfg = Release|Any CPU + {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x86.Build.0 = Release|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x64.Build.0 = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x86.Build.0 = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x64.Build.0 = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x86.Build.0 = Debug|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|Any CPU.Build.0 = Release|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x64.ActiveCfg = Release|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x64.Build.0 = Release|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x86.ActiveCfg = Release|Any CPU + {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x86.Build.0 = Release|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x64.Build.0 = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x86.Build.0 = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x64.Build.0 = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x86.Build.0 = Debug|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|Any CPU.Build.0 = Release|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x64.ActiveCfg = Release|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x64.Build.0 = Release|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x86.ActiveCfg = Release|Any CPU + {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x86.Build.0 = Release|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x64.Build.0 = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x86.Build.0 = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x64.Build.0 = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x86.Build.0 = Debug|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|Any CPU.Build.0 = Release|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x64.ActiveCfg = Release|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x64.Build.0 = Release|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x86.ActiveCfg = Release|Any CPU + {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x86.Build.0 = Release|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x64.ActiveCfg = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x64.Build.0 = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x86.ActiveCfg = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x86.Build.0 = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x64.Build.0 = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x86.Build.0 = Debug|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|Any CPU.Build.0 = Release|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x64.ActiveCfg = Release|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x64.Build.0 = Release|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x86.ActiveCfg = Release|Any CPU + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x86.Build.0 = Release|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x64.Build.0 = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x86.Build.0 = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x64.Build.0 = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x86.Build.0 = Debug|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|Any CPU.Build.0 = Release|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x64.ActiveCfg = Release|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x64.Build.0 = Release|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x86.ActiveCfg = Release|Any CPU + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x86.Build.0 = Release|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x64.ActiveCfg = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x64.Build.0 = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x86.ActiveCfg = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x86.Build.0 = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x64.Build.0 = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x86.Build.0 = Debug|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|Any CPU.Build.0 = Release|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x64.ActiveCfg = Release|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x64.Build.0 = Release|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x86.ActiveCfg = Release|Any CPU + {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x86.Build.0 = Release|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x64.Build.0 = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x86.Build.0 = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x64.Build.0 = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x86.Build.0 = Debug|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|Any CPU.Build.0 = Release|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x64.ActiveCfg = Release|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x64.Build.0 = Release|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x86.ActiveCfg = Release|Any CPU + {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x86.Build.0 = Release|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x64.Build.0 = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x86.Build.0 = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x64.Build.0 = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x86.Build.0 = Debug|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|Any CPU.Build.0 = Release|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x64.ActiveCfg = Release|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x64.Build.0 = Release|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x86.ActiveCfg = Release|Any CPU + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x86.Build.0 = Release|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x64.Build.0 = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x86.Build.0 = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x64.Build.0 = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x86.Build.0 = Debug|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|Any CPU.Build.0 = Release|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x64.ActiveCfg = Release|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x64.Build.0 = Release|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x86.ActiveCfg = Release|Any CPU + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x86.Build.0 = Release|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x64.ActiveCfg = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x64.Build.0 = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x86.ActiveCfg = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x86.Build.0 = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x64.Build.0 = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x86.Build.0 = Debug|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|Any CPU.Build.0 = Release|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x64.ActiveCfg = Release|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x64.Build.0 = Release|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x86.ActiveCfg = Release|Any CPU + {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x86.Build.0 = Release|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x64.Build.0 = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x86.Build.0 = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x64.Build.0 = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x86.Build.0 = Debug|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|Any CPU.Build.0 = Release|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x64.ActiveCfg = Release|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x64.Build.0 = Release|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x86.ActiveCfg = Release|Any CPU + {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x86.Build.0 = Release|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x64.Build.0 = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x86.Build.0 = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|Any CPU.Build.0 = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x64.ActiveCfg = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x64.Build.0 = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x86.ActiveCfg = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x86.Build.0 = Debug|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|Any CPU.Build.0 = Release|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x64.ActiveCfg = Release|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x64.Build.0 = Release|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x86.ActiveCfg = Release|Any CPU + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -777,8 +1014,6 @@ Global {D617C732-6C2E-4AEF-8E1A-A4328EC32104} = {640C178B-FA29-49AD-8245-FA7EE934D522} {0B925B7B-4E10-41CB-B6F9-708E134674CE} = {B73A108B-9163-4463-B755-A0F84344D088} {2D52ACC2-6D22-4C31-A90C-505D04980EEF} = {640C178B-FA29-49AD-8245-FA7EE934D522} - {A634BD23-B31B-4260-922E-24FEC8B4CF22} = {4B854644-1F54-4ECA-9290-A89B91A19FF9} - {9191CC9B-4DD0-4115-8FFD-A710659DBC27} = {640C178B-FA29-49AD-8245-FA7EE934D522} {63DEA604-5D81-4C3A-AF43-0A296372EB20} = {640C178B-FA29-49AD-8245-FA7EE934D522} {872BDB24-9C83-4089-A5ED-086CAC6103B1} = {640C178B-FA29-49AD-8245-FA7EE934D522} {A933AA5E-A9EE-4FED-8046-3663C4FF6C61} = {2D52ACC2-6D22-4C31-A90C-505D04980EEF} @@ -794,6 +1029,21 @@ Global {FDB5712F-2EB4-4A36-9DEF-955B3575024D} = {4B854644-1F54-4ECA-9290-A89B91A19FF9} {147096AD-4426-42DD-AD6C-8EDD527F7C55} = {640C178B-FA29-49AD-8245-FA7EE934D522} {B899C796-D7D0-4C02-AEAE-E2C0DD61802E} = {4B854644-1F54-4ECA-9290-A89B91A19FF9} + {A5033865-E5C6-4D6D-9101-2A3D2C2802BE} = {522A1681-215E-4C39-B1AC-1324F7CE516E} + {BC60574D-16B6-45FA-8304-9B1C8C4B461D} = {A5033865-E5C6-4D6D-9101-2A3D2C2802BE} + {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} = {4B854644-1F54-4ECA-9290-A89B91A19FF9} + {3805EA18-B451-063D-B22C-8342113DBFBF} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {0AB89BB3-8C37-5C1C-66DC-348052B379A9} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {860FEFA9-6FD6-5F81-A3ED-65B532691C8F} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {239D5685-73B4-3B87-1C71-8B5F86A87BE5} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {B7B2B726-59D4-EE71-3853-DE2942846E49} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {02AA470A-D838-4674-8373-D81C848E5138} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC} = {02AA470A-D838-4674-8373-D81C848E5138} + {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4} = {4B854644-1F54-4ECA-9290-A89B91A19FF9} + {927A9F26-8854-4AD1-15B3-4669C0842D88} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {1AAB0311-6318-870E-2F18-9F71A52C7B31} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} + {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4C22E2C0-B5D3-497F-BAD0-EB769D6D6CD1} diff --git a/_tests/Directory.Packages.props b/_tests/Directory.Packages.props index 134a95556..54c9ed1d6 100644 --- a/_tests/Directory.Packages.props +++ b/_tests/Directory.Packages.props @@ -16,26 +16,26 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + @@ -47,6 +47,6 @@ - + \ No newline at end of file diff --git a/_tests/Udap.CA.Tests/Udap.CA.Tests.csproj b/_tests/Udap.CA.Tests/Udap.CA.Tests.csproj deleted file mode 100644 index aa26cfe82..000000000 --- a/_tests/Udap.CA.Tests/Udap.CA.Tests.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - - net10.0 - enable - enable - - false - - - - 0 - - - - 0 - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - diff --git a/_tests/Udap.CA.Tests/UnitTest1.cs b/_tests/Udap.CA.Tests/UnitTest1.cs deleted file mode 100644 index f189964fb..000000000 --- a/_tests/Udap.CA.Tests/UnitTest1.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Formats.Asn1; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Udap.CA.Services; -using Udap.Util.Extensions; -// ReSharper disable All - - -namespace Udap.CA.Tests; - -public class UnitTest1 -{ - [Fact] - public void GenerateRootCertTestWithDispose() - { - var subject = "CN=SureFhir-TestCA, OU=Root, O=Fhir Coding, L=Portland, S=Oregon, C=US"; - - using var certificateUtilities = new CertificateUtilities(); - var rootCert = certificateUtilities.GenerateRootCA(subject); - - Assert.Equal(subject, rootCert.Subject); - Assert.True(rootCert.HasPrivateKey); - Assert.Equal(subject, rootCert.Issuer); - - certificateUtilities.Dispose(); - - // - // Just exercising dispose test behavior - // - Action action = () => rootCert.Subject.ToString(); - var ex = Assert.Throws(action); - Assert.Equal("m_safeCertContext is an invalid handle.", ex.Message, StringComparer.OrdinalIgnoreCase); - } - - - public X509Certificate2 GenerateRootCertTest() - { - var subject = "CN=SureFhir-TestCA, OU=Root, O=Fhir Coding, L=Portland, S=Oregon, C=US"; - - var certificateUtilities = new CertificateUtilities(); - var rootCert = certificateUtilities.GenerateRootCA(subject); - - Assert.Equal(subject, rootCert.Subject); - Assert.True(rootCert.HasPrivateKey); - Assert.Equal(subject, rootCert.Issuer); - - return rootCert; - } - - - public X509Certificate2 GenerateIntermediateCertTest() - { - var rootCertificate = GenerateRootCertTest(); - - var subject = "CN=SureFhir-TestAnchor, OU=Anchor, O=Fhir Coding, L=Portland, S=Oregon, C=US"; - var subjectAltName = new Uri("http://fhirwalker.com"); - var crl = new Uri("http://crl.fhircerts.net/crl/SureFhir-TestAnchor.crl"); - var certificateAuthIssuerUri = new Uri("http://crl.fhircerts.net/certs/intermediates/SureFhir-TestAnchor.cer"); - - var certificateUtilities = new CertificateUtilities(); - - var intermediateCertificate = certificateUtilities.GenerateIntermediate( - subject, - subjectAltName, - crl, - certificateAuthIssuerUri, - rootCertificate); - - Assert.Equal(subject, intermediateCertificate.Subject); - Assert.True(intermediateCertificate.HasPrivateKey); - Assert.Equal(rootCertificate.Subject, intermediateCertificate.Issuer); - - return intermediateCertificate; - } - - [Fact] - public void GenerateIssuedCertTest() - { - var intermediateCertificate = GenerateIntermediateCertTest(); - - var subject = "CN=test.fhirlabs.net, OU=Do not use for PHI, O=Fhir Coding, L=Portland, S=Oregon, C=US"; - var subjectAltName = new Uri("https://test.fhirlabs.net/fhir/r4"); - var crl = new Uri("http://crl.fhircerts.net/crl/surefhirlabs.crl"); - var certificateAuthIssuerUri = new Uri("http://crl.fhircerts.net/certs/intermediates/SureFhirLabs_Intermediate.cer"); - - var certificateUtilities = new CertificateUtilities(); - - var issuedCertificate = certificateUtilities.GenerateEndCert( - subject, - subjectAltName, - crl, - certificateAuthIssuerUri, - intermediateCertificate); - - - Assert.Equal(subject, issuedCertificate.Subject); - // - // It might be cool to try using the Microsoft AsnReader instead of BouncyCastle. - // Good resources here: - // https://stackoverflow.com/questions/70217305/how-to-use-system-formats-asn1-asnreader - // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Formats.Asn1/tests/Reader/ReadSequence.cs - // https://github.com/dotnet/designs/blob/ec974c0b7d87d984f498651af2a3e157ba579f01/accepted/2020/asnreader/asnreader.md - // - issuedCertificate.GetExtensionValue("1.3.6.1.5.5.7.1.1"); - - Assert.True(issuedCertificate.HasPrivateKey); - Assert.Equal(intermediateCertificate.Subject, issuedCertificate.Issuer); - - var aiaExtensions = - issuedCertificate.Extensions["1.3.6.1.5.5.7.1.1"] as X509AuthorityInformationAccessExtension; - Assert.NotNull(aiaExtensions); - Assert.Equal(certificateAuthIssuerUri.AbsoluteUri, aiaExtensions!.EnumerateCAIssuersUris().Single()); - - // - // No good because it just gets the first one - // - var joe = issuedCertificate.GetNameInfo(X509NameType.UrlName, false); - - - var subjectAltNameExtension = - issuedCertificate.Extensions["2.5.29.17"] as X509SubjectAlternativeNameExtension; - - // - // This is but wont work for UDAP because the string sent to MatchesHostname must be a domain name - // test.fhirlabs.net would work but test.fhirlabs.net/fhir/r4 would not - // - // Actually MatchesHostname is not build for checking a Uri. Internally is gets a X509SubjectAlternativeNameExtension - // like on the previous code line and has access to two enumerates methods; EnumerateDnsNames() and EnumerateIPAddresses(). - // There is no EnumerateUris(). So we should write one. TODO - // - // So the next line would work great as an SSL validation but not for UDAP - // issuedCertificate.MatchesHostname("test.fhirlabs.net").Should().BeTrue(); - // - // Below is some code that uses the AsnReader to find the URI - - - // char[] clientPem = PemEncoding.Write("CERTIFICATE", issuedCertificate.RawData); - // File.WriteAllBytes($"__Test__.cer", clientPem.Select(c => (byte)c).ToArray()); - - - - //TODO: this code needs to be put into a library so we can used it to assert subAltName the same as iss and url etc... - ReadOnlyMemory encoded = subjectAltNameExtension.RawData; - AsnReader reader = new AsnReader(subjectAltNameExtension.RawData, AsnEncodingRules.DER); - Assert.True(reader.HasData); - AsnReader sanExtensionValue = reader.ReadSequence(); - Assert.False(reader.HasData); - Asn1Tag uriName = new Asn1Tag(TagClass.ContextSpecific, 6); - - // - // extra subAltName for what if scenario for now. Future work. - // - // sanExtensionValue.ReadCharacterString(UniversalTagNumber.IA5String, uriName).Should().Be("http://localhost/"); - - Assert.Equal(subjectAltName.AbsoluteUri, sanExtensionValue.ReadCharacterString(UniversalTagNumber.IA5String, uriName)); - - } - -} \ No newline at end of file diff --git a/_tests/Udap.CA.Tests/Usings.cs b/_tests/Udap.CA.Tests/Usings.cs deleted file mode 100644 index 8c927eb74..000000000 --- a/_tests/Udap.CA.Tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs b/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs index 6e76eb32a..6311cd640 100644 --- a/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs +++ b/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs @@ -150,4 +150,114 @@ public void TestHl7B2BExtensionSerialization() b2BHl7 = JsonSerializer.Deserialize(b2BHl7.SerializeToJson()); Assert.Equal(0, b2BHl7!.PurposeOfUse!.Count); } + + [Fact] + public void ClientCredentials_WithCertificateChain_IncludesMultipleX5cEntries() + { + var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx"); + var clientCert = new X509Certificate2(certPath, "udap-test"); + + var intermediatePath = Path.Combine(AppContext.BaseDirectory, "CertStore/intermediates", "SureFhirLabs_Intermediate.cer"); + var intermediateCert = new X509Certificate2(intermediatePath); + + var certificates = new List { clientCert, intermediateCert }; + + var clientRequest = AccessTokenRequestForClientCredentialsBuilder.Create( + "test-client-id", + "https://server/connect/token", + certificates) + .WithScope("system/Patient.rs") + .Build("RS384"); + + Assert.NotNull(clientRequest.ClientAssertion.Value); + + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(clientRequest.ClientAssertion.Value); + + // Verify x5c header contains two certificates + Assert.True(jwtToken.Header.ContainsKey("x5c")); + var x5cArray = jwtToken.Header["x5c"] as List; + Assert.NotNull(x5cArray); + Assert.Equal(2, x5cArray.Count); + } + + [Fact] + public void AuthorizationCode_WithCertificateChain_IncludesMultipleX5cEntries() + { + var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx"); + var clientCert = new X509Certificate2(certPath, "udap-test"); + + var intermediatePath = Path.Combine(AppContext.BaseDirectory, "CertStore/intermediates", "SureFhirLabs_Intermediate.cer"); + var intermediateCert = new X509Certificate2(intermediatePath); + + var certificates = new List { clientCert, intermediateCert }; + + var tokenRequest = AccessTokenRequestForAuthorizationCodeBuilder.Create( + "test-client-id", + "https://server/connect/token", + certificates, + "https://client/callback", + "test-auth-code") + .Build("RS384"); + + Assert.NotNull(tokenRequest.ClientAssertion.Value); + + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(tokenRequest.ClientAssertion.Value); + + // Verify x5c header contains two certificates + Assert.True(jwtToken.Header.ContainsKey("x5c")); + var x5cArray = jwtToken.Header["x5c"] as List; + Assert.NotNull(x5cArray); + Assert.Equal(2, x5cArray.Count); + } + + [Fact] + public void ClientCredentials_SingleCert_StillWorks() + { + var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx"); + var clientCert = new X509Certificate2(certPath, "udap-test"); + + var clientRequest = AccessTokenRequestForClientCredentialsBuilder.Create( + "test-client-id", + "https://server/connect/token", + clientCert) + .WithScope("system/Patient.rs") + .Build("RS384"); + + Assert.NotNull(clientRequest.ClientAssertion.Value); + + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(clientRequest.ClientAssertion.Value); + + Assert.True(jwtToken.Header.ContainsKey("x5c")); + var x5cArray = jwtToken.Header["x5c"] as List; + Assert.NotNull(x5cArray); + Assert.Single(x5cArray); + } + + [Fact] + public void AuthorizationCode_SingleCert_StillWorks() + { + var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx"); + var clientCert = new X509Certificate2(certPath, "udap-test"); + + var tokenRequest = AccessTokenRequestForAuthorizationCodeBuilder.Create( + "test-client-id", + "https://server/connect/token", + clientCert, + "https://client/callback", + "test-auth-code") + .Build("RS384"); + + Assert.NotNull(tokenRequest.ClientAssertion.Value); + + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(tokenRequest.ClientAssertion.Value); + + Assert.True(jwtToken.Header.ContainsKey("x5c")); + var x5cArray = jwtToken.Header["x5c"] as List; + Assert.NotNull(x5cArray); + Assert.Single(x5cArray); + } } diff --git a/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs b/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs index 545c49b3b..c5801e63a 100644 --- a/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs +++ b/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs @@ -11,6 +11,7 @@ using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Duende.IdentityModel; +using Microsoft.IdentityModel.Tokens; using Udap.Model.Registration; using Xunit.Abstractions; @@ -528,11 +529,94 @@ public void ClampedExpiration_CertificateSoonButNotTooSoon_IsClampedNotThrown() // Allow tiny drift Assert.True((expected - expDt).Duration() < TimeSpan.FromSeconds(2)); } + + [Fact] + public void AdditionalClaims_SurviveSerializationRoundTrip() + { + var document = new UdapCertificationAndEndorsementDocument("TEFCA Basic App Certification") + { + Issuer = "urn:oid:2.999#T-TRTMNT", + Subject = "urn:oid:2.999#T-TRTMNT", + Scope = "user/*.read", + AdditionalClaims = new Dictionary + { + ["exchange_purposes"] = JsonSerializer.SerializeToElement( + new[] { "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS" }), + ["home_community_id"] = JsonSerializer.SerializeToElement("urn:oid:2.999") + } + }; + + var json = document.SerializeToJson(); + _testOutputHelper.WriteLine(json); + + // Verify additional claims are in the serialized JSON + var parsed = JsonDocument.Parse(json); + Assert.True(parsed.RootElement.TryGetProperty("exchange_purposes", out var ep)); + Assert.Equal(JsonValueKind.Array, ep.ValueKind); + Assert.Equal("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS", ep[0].GetString()); + + Assert.True(parsed.RootElement.TryGetProperty("home_community_id", out var hc)); + Assert.Equal("urn:oid:2.999", hc.GetString()); + + // Deserialize back and verify additional claims survive + var deserialized = JsonSerializer.Deserialize(json); + Assert.NotNull(deserialized); + Assert.Equal("TEFCA Basic App Certification", deserialized!.CertificationName); + Assert.Equal("user/*.read", deserialized.Scope); + Assert.NotNull(deserialized.AdditionalClaims); + Assert.True(deserialized.AdditionalClaims!.ContainsKey("exchange_purposes")); + Assert.True(deserialized.AdditionalClaims.ContainsKey("home_community_id")); + + // Re-serialize and verify claims are still present + var reJson = deserialized.SerializeToJson(); + var reParsed = JsonDocument.Parse(reJson); + Assert.True(reParsed.RootElement.TryGetProperty("exchange_purposes", out _)); + Assert.True(reParsed.RootElement.TryGetProperty("home_community_id", out _)); + } + + [Fact] + public void AdditionalClaims_IncludedInSignedSoftwareStatement() + { + var certificationCert = + new X509Certificate2(Path.Combine("CertStore/issued", "FhirLabsAdminCertification.pfx"), "udap-test"); + + var signedJwt = UdapCertificationsAndEndorsementBuilder + .Create("TEFCA Basic App Certification", certificationCert) + .WithClampedExpiration(TimeSpan.FromMinutes(5)) + .WithCertificationDescription("TEFCA Basic App Certification") + .WithCertificationUris(new List + { "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification" }) + .WithScope("user/*.read") + .WithTokenEndpointAuthMethod("private_key_jwt") + .WithAdditionalClaims(new Dictionary + { + ["exchange_purposes"] = JsonSerializer.SerializeToElement( + new[] { "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS" }), + ["home_community_id"] = JsonSerializer.SerializeToElement( + "2.16.840.1.113883.3.2054.2.4") + }) + .BuildSoftwareStatement(); + + Assert.NotNull(signedJwt); + + // Decode payload and verify additional claims are present + var parts = signedJwt.Split('.'); + Assert.Equal(3, parts.Length); + + var payloadJson = Base64UrlEncoder.Decode(parts[1]); + _testOutputHelper.WriteLine(payloadJson); + + var payload = JsonDocument.Parse(payloadJson); + Assert.True(payload.RootElement.TryGetProperty("exchange_purposes", out var ep)); + Assert.Equal("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS", ep[0].GetString()); + Assert.True(payload.RootElement.TryGetProperty("home_community_id", out var hc)); + Assert.Equal("2.16.840.1.113883.3.2054.2.4", hc.GetString()); + } } // // Register with only client_credentials C&E and then fail when a toke is requested for authorization_code. - // Even if the standard registration contained authorization_code. This + // Even if the standard registration contained authorization_code. This // // diff --git a/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs b/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs index ffb69ab6f..2efa02d36 100644 --- a/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs +++ b/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs @@ -416,7 +416,6 @@ public void TestTefcaIasExtensionSerialization() { UserInformation = relatedPersonElement, PatientInformation = patientElement, - IalVetted = identityTokenElement, IdToken = identityTokenElement }; @@ -430,14 +429,11 @@ public void TestTefcaIasExtensionSerialization() // _testOutputHelper.WriteLine(serializeDocument); - Assert.Contains("T-IAS", serializeDocument); Assert.Contains("https://udaped.fhirlabs.net/Policy/Consent/99", serializeDocument); Assert.Contains("https://fhirlabs.net/fhir/r4/Consent/99", serializeDocument); tefcaIas = JsonSerializer.Deserialize(serializeDocument); - Assert.Equal("T-IAS", tefcaIas!.PurposeOfUse); - tefcaIas.ConsentPolicy!.Remove("https://udaped.fhirlabs.net/Policy/Consent/99"); Assert.Equal(0, tefcaIas.ConsentPolicy.Count); @@ -445,10 +441,6 @@ public void TestTefcaIasExtensionSerialization() tefcaIas = JsonSerializer.Deserialize(tefcaIas.SerializeToJson()); Assert.Equal(0, tefcaIas!.ConsentPolicy!.Count); - Assert.Equal(identityTokenElement.GetRawText() - .Replace("\n", "").Replace("\r", "").Replace(": ", ":").Replace(", ", ","), - tefcaIas.IalVetted?.GetRawText(), StringComparer.OrdinalIgnoreCase); - Assert.Equal(identityTokenElement.GetRawText() .Replace("\n", "").Replace("\r", "").Replace(": ", ":").Replace(", ", ","), tefcaIas.IdToken?.GetRawText(), StringComparer.OrdinalIgnoreCase); @@ -753,14 +745,13 @@ public void TefcaIasExtensionValidationTest() { var hl7B2BUser = new TEFCAIASAuthorizationExtension() { - Version = null!, - PurposeOfUse = "Bad" + Version = null! }; var notes = hl7B2BUser.Validate(); Assert.NotNull(notes); - Assert.Equal(4, notes.Count); - Assert.Equal(new List { "Missing required version", "Missing required user_information", "Missing required patient_information", "purpose_of_use must be T-IAS" }, notes); + Assert.Equal(3, notes.Count); + Assert.Equal(new List { "Missing required version", "Missing required user_information", "Missing required patient_information" }, notes); } [Fact] diff --git a/_tests/Udap.Common.Tests/Model/Tefca/TEFCAAuthorizationErrorExtensionTests.cs b/_tests/Udap.Common.Tests/Model/Tefca/TEFCAAuthorizationErrorExtensionTests.cs new file mode 100644 index 000000000..2403dfffb --- /dev/null +++ b/_tests/Udap.Common.Tests/Model/Tefca/TEFCAAuthorizationErrorExtensionTests.cs @@ -0,0 +1,93 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Text.Json; +using Udap.Tefca.Model; +using Xunit; + +namespace Udap.Common.Tests.Model.Tefca; + +/// +/// Tests for . +/// +/// +/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 B2B #3, Table 1 +/// +public class TEFCAAuthorizationErrorExtensionTests +{ + [Fact] + public void Serialize_ConsentRequired_Only() + { + var error = new TEFCAAuthorizationErrorExtension + { + ConsentRequired = ["urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1"] + }; + + var json = JsonSerializer.Serialize(error); + using var parsed = JsonDocument.Parse(json); + var root = parsed.RootElement; + + Assert.True(root.TryGetProperty("consent_required", out var cr)); + Assert.Equal(JsonValueKind.Array, cr.ValueKind); + Assert.Single(cr.EnumerateArray()); + Assert.Equal("urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1", cr[0].GetString()); + + Assert.True(root.TryGetProperty("consent_form", out var cf)); + Assert.Equal(JsonValueKind.Null, cf.ValueKind); + } + + [Fact] + public void Serialize_ConsentRequired_With_ConsentForm() + { + var error = new TEFCAAuthorizationErrorExtension + { + ConsentRequired = [ + "urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1", + "urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.2" + ], + ConsentForm = "https://tefca.example.com/consent/form.pdf" + }; + + var json = JsonSerializer.Serialize(error); + using var parsed = JsonDocument.Parse(json); + var root = parsed.RootElement; + + Assert.True(root.TryGetProperty("consent_required", out var cr)); + Assert.Equal(2, cr.GetArrayLength()); + + Assert.True(root.TryGetProperty("consent_form", out var cf)); + Assert.Equal("https://tefca.example.com/consent/form.pdf", cf.GetString()); + } + + [Fact] + public void Roundtrip_Deserialization() + { + var original = new TEFCAAuthorizationErrorExtension + { + ConsentRequired = ["urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1"], + ConsentForm = "https://tefca.example.com/consent/form.pdf" + }; + + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.ConsentRequired); + Assert.Contains("urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1", deserialized.ConsentRequired); + Assert.Equal("https://tefca.example.com/consent/form.pdf", deserialized.ConsentForm); + } + + [Fact] + public void Default_ConsentRequired_Is_Empty() + { + var error = new TEFCAAuthorizationErrorExtension(); + Assert.Empty(error.ConsentRequired); + Assert.Null(error.ConsentForm); + } +} diff --git a/_tests/Udap.Common.Tests/Model/Tefca/TefcaCertificationDocumentTests.cs b/_tests/Udap.Common.Tests/Model/Tefca/TefcaCertificationDocumentTests.cs new file mode 100644 index 000000000..14f8000fd --- /dev/null +++ b/_tests/Udap.Common.Tests/Model/Tefca/TefcaCertificationDocumentTests.cs @@ -0,0 +1,116 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Text.Json; +using Udap.Tefca.Model; +using Xunit; + +namespace Udap.Common.Tests.Model.Tefca; + +/// +/// Tests for . +/// +/// +/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 Registration #6 +/// +public class TefcaCertificationDocumentTests +{ + [Fact] + public void Constructor_Sets_CertificationName_And_Uris() + { + var doc = new TefcaCertificationDocument(); + + Assert.Equal(TefcaConstants.Certification.BasicAppCertificationName, doc.CertificationName); + Assert.Single(doc.CertificationUris!); + Assert.Contains(TefcaConstants.Certification.BasicAppCertificationUri, doc.CertificationUris!); + } + + [Fact] + public void Serialize_Includes_ExchangePurposes() + { + var doc = new TefcaCertificationDocument + { + ExchangePurposes = [TefcaConstants.ExchangePurposeCodes.Treatment] + }; + + var json = doc.SerializeToJson(); + using var parsed = JsonDocument.Parse(json); + var root = parsed.RootElement; + + Assert.True(root.TryGetProperty("exchange_purposes", out var xp)); + Assert.Equal(JsonValueKind.Array, xp.ValueKind); + Assert.Single(xp.EnumerateArray()); + Assert.Equal("T-TREAT", xp[0].GetString()); + } + + [Fact] + public void Serialize_Includes_HomeCommunityId() + { + var doc = new TefcaCertificationDocument + { + HomeCommunityId = "urn:oid:1.2.3.4.5" + }; + + var json = doc.SerializeToJson(); + using var parsed = JsonDocument.Parse(json); + var root = parsed.RootElement; + + Assert.True(root.TryGetProperty("home_community_id", out var hcid)); + Assert.Equal("urn:oid:1.2.3.4.5", hcid.GetString()); + } + + [Fact] + public void Serialize_Includes_Base_And_Tefca_Claims() + { + var doc = new TefcaCertificationDocument + { + Issuer = "https://rce.example.com", + Subject = "https://app.example.com", + ExchangePurposes = [TefcaConstants.ExchangePurposeCodes.IndividualAccessServices], + HomeCommunityId = "urn:oid:1.2.3.4.5" + }; + + var json = doc.SerializeToJson(); + using var parsed = JsonDocument.Parse(json); + var root = parsed.RootElement; + + // Base claims + Assert.True(root.TryGetProperty("iss", out var iss)); + Assert.Equal("https://rce.example.com", iss.GetString()); + Assert.True(root.TryGetProperty("certification_name", out var cn)); + Assert.Equal("TEFCA Basic App Certification", cn.GetString()); + Assert.True(root.TryGetProperty("certification_uris", out _)); + + // TEFCA claims + Assert.True(root.TryGetProperty("exchange_purposes", out var xp)); + Assert.Equal("T-IAS", xp[0].GetString()); + Assert.True(root.TryGetProperty("home_community_id", out _)); + } + + [Fact] + public void Roundtrip_Deserialization() + { + var doc = new TefcaCertificationDocument + { + Issuer = "https://rce.example.com", + Subject = "https://app.example.com", + ExchangePurposes = [TefcaConstants.ExchangePurposeCodes.TefcaRequiredTreatment], + HomeCommunityId = "urn:oid:9.8.7.6" + }; + + var json = doc.SerializeToJson(); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal("TEFCA Basic App Certification", deserialized.CertificationName); + Assert.Single(deserialized.ExchangePurposes!); + Assert.Contains("T-TRTMNT", deserialized.ExchangePurposes!); + Assert.Equal("urn:oid:9.8.7.6", deserialized.HomeCommunityId); + } +} diff --git a/_tests/Udap.PKI.Generator/BuildTestCerts.cs b/_tests/Udap.PKI.Generator/BuildTestCerts.cs index 63a3c0f8a..2132fd9e5 100644 --- a/_tests/Udap.PKI.Generator/BuildTestCerts.cs +++ b/_tests/Udap.PKI.Generator/BuildTestCerts.cs @@ -105,7 +105,7 @@ public BuildTestCerts(ITestOutputHelper testOutputHelper) /// default community uri = udap://fhirlabs.net /// /// - [Fact] + [Fact]//(Skip = "Enabled on desktop when needed.")] public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by method name. Notice ITestCaseOrderer { Console.WriteLine("*************************************"); @@ -211,6 +211,31 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by SureFhirlabsUdapIssued.EnsureDirectoryExists(); + BuildEndCertsForDefaultCommunity(caCert, intermediateCertWithoutKey, intermediateCertWithKey, intermediateRSAKey); + } + } + + /// + /// Regenerate all end-entity certificates (UDAP client, ECDSA, SSL, CRLs) for the default community + /// using the existing CA and intermediate from disk. Does NOT rebuild the PKI. + /// + [Fact(Skip = "Enabled on desktop when needed.")] + public void MakeEndCertsForDefaultCommunity() + { + using var caCert = new X509Certificate2($"{SureFhirLabsCertStore}/SureFhirLabs_CA.pfx", "udap-test", X509KeyStorageFlags.Exportable); + using var subCA = new X509Certificate2($"{SureFhirlabsUdapIntermediates}/SureFhirLabs_Intermediate.pfx", "udap-test", X509KeyStorageFlags.Exportable); + + SureFhirlabsUdapIssued.EnsureDirectoryExists(); + + BuildEndCertsForDefaultCommunity(caCert, subCA, subCA, subCA.GetRSAPrivateKey()!); + } + + private void BuildEndCertsForDefaultCommunity( + X509Certificate2 caCert, + X509Certificate2 intermediateCertWithoutKey, + X509Certificate2 intermediateCertWithKey, + RSA intermediateRSAKey) + { #region weatherapi.lab Client (Issued) Certificates BuildUdapClientCertificate( @@ -291,66 +316,7 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by SureFhirLabsIntermediatePublicCertHosted ); #endregion - - #region weatherapi.lab SSL - - // using RSA rsaWeatherApiSsl = RSA.Create(2048); - // var sslReq = new CertificateRequest( - // "CN=weatherapi.lab, OU=SSL, O=Fhir Coding, L=Portland, S=Oregon, C=US", - // rsaWeatherApiSsl, - // HashAlgorithmName.SHA256, - // RSASignaturePadding.Pkcs1); - // - // sslReq.CertificateExtensions.Add( - // new X509BasicConstraintsExtension(false, false, 0, true)); - // - // sslReq.CertificateExtensions.Add( - // new X509KeyUsageExtension( - // X509KeyUsageFlags.DigitalSignature, - // true)); - // - // sslReq.CertificateExtensions.Add( - // new X509SubjectKeyIdentifierExtension(sslReq.PublicKey, false)); - // - // AddAuthorityKeyIdentifier(intermediateCertWithoutKey, sslReq, _testOutputHelper); - // sslReq.CertificateExtensions.Add(MakeCdp(SureFhirLabsIntermediateCrl)); - // - // subAltNameBuilder = new SubjectAlternativeNameBuilder(); - // subAltNameBuilder.AddDnsName("weatherapi.lab"); - // x509Extension = subAltNameBuilder.Build(); - // sslReq.CertificateExtensions.Add(x509Extension); - // - // sslReq.CertificateExtensions.Add( - // new X509EnhancedKeyUsageExtension( - // new OidCollection { - // new Oid("1.3.6.1.5.5.7.3.2"), // TLS Client auth - // new Oid("1.3.6.1.5.5.7.3.1"), // TLS Server auth - // }, - // true)); - // - // using (var clientCert = sslReq.Create( - // intermediateCertWithKey, - // DateTimeOffset.UtcNow.AddDays(-1), - // DateTimeOffset.UtcNow.AddYears(2), - // new ReadOnlySpan(RandomNumberGenerator.GetBytes(16)))) - // { - // // Do something with these certs, like export them to PFX, - // // or add them to an X509Store, or whatever. - // var sslCert = clientCert.CopyWithPrivateKey(rsaWeatherApiSsl); - // - // SureFhirLabsSslWeatherApi.EnsureDirectoryExists(); - // var clientBytes = sslCert.Export(X509ContentType.Pkcs12, "udap-test"); - // - // Console.WriteLine("*************************************"); - // Console.WriteLine($"{SureFhirLabsSslWeatherApi}/weatherapi.lab.pfx"); - // Console.WriteLine("*************************************"); - // - // File.WriteAllBytes($"{SureFhirLabsSslWeatherApi}/weatherapi.lab.pfx", clientBytes); - // char[] certificatePem = PemEncoding.Write("CERTIFICATE", clientCert.RawData); - // File.WriteAllBytes($"{SureFhirLabsSslWeatherApi}/weatherapi.lab.cer", certificatePem.Select(c => (byte)c).ToArray()); - // } - #endregion - + #region fhirlabs.net SSL using RSA rsaFhirLabsSsl = RSA.Create(2048); @@ -375,9 +341,9 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by AddAuthorityKeyIdentifier(intermediateCertWithoutKey, sureFhirSSLReq, _testOutputHelper); sureFhirSSLReq.CertificateExtensions.Add(MakeCdp(SureFhirLabsIntermediateCrl)); - subAltNameBuilder = new SubjectAlternativeNameBuilder(); + var subAltNameBuilder = new SubjectAlternativeNameBuilder(); subAltNameBuilder.AddDnsName("fhirlabs.net"); - x509Extension = subAltNameBuilder.Build(); + var x509Extension = subAltNameBuilder.Build(); sureFhirSSLReq.CertificateExtensions.Add(x509Extension); sureFhirSSLReq.CertificateExtensions.Add( @@ -408,7 +374,7 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by #endregion - #region securedcontrols.net SSL :: Identity Provider + #region securedcontrols.net SSL :: Identity Provider using RSA rsaSecuredControls = RSA.Create(2048); @@ -556,7 +522,7 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by new X509SubjectKeyIdentifierExtension(hostDockerInternal.PublicKey, false)); AddAuthorityKeyIdentifier(caCert, hostDockerInternal, _testOutputHelper); - // hostDockerInternal.CertificateExtensions.Add(MakeCdp(SureFhirLabsRootCrl)); + // hostDockerInternal.CertificateExtensions.Add(MakeCdp(SureFhirLabsRootCrl)); subAltNameBuilder = new SubjectAlternativeNameBuilder(); subAltNameBuilder.AddDnsName("host.docker.internal"); @@ -590,7 +556,6 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by } #endregion - } //Distribute @@ -602,8 +567,9 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by File.Copy($"{SureFhirlabsUdapIssued}/fhirlabs.net.client.pfx", $"{BaseDir}/../../examples/FhirLabsApi/CertStore/issued/fhirlabs.net.client.pfx", true); - - + + + // // Udap.Common.Tests // @@ -625,12 +591,12 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by - // Copy CA to FhirLabsApi so it can be added to the Docker Container trust store. + // Copy CA to FhirLabsApi so it can be added to the Docker Container trust store. File.Copy($"{SureFhirLabsCertStore}/SureFhirLabs_CA.cer", $"{BaseDir}/../../examples/FhirLabsApi/SureFhirLabs_CA.cer", true); - // Copy CA to Udap.Auth.Server so it can be added to the Docker Container trust store. + // Copy CA to Udap.Auth.Server so it can be added to the Docker Container trust store. File.Copy($"{SureFhirLabsCertStore}/SureFhirLabs_CA.cer", $"{BaseDir}/../../examples/Udap.Auth.Server/SureFhirLabs_CA.cer", true); @@ -646,10 +612,6 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by $"{BaseDir}/../../examples/FhirLabsApi/host.docker.internal.pfx", true); - File.Copy($"{SureFhirLabsSslIdentityServer}/host.docker.internal.pfx", - $"{BaseDir}/../../examples/Udap.CA/host.docker.internal.pfx", - true); - File.Copy($"{SureFhirLabsSslIdentityServer}/host.docker.internal.pfx", $"{BaseDir}/../../examples/Udap.Auth.Server.Admin/host.docker.internal.pfx", true); diff --git a/_tests/Udap.PKI.Generator/TefcaBuild.cs b/_tests/Udap.PKI.Generator/TefcaBuild.cs new file mode 100644 index 000000000..6b0227ec8 --- /dev/null +++ b/_tests/Udap.PKI.Generator/TefcaBuild.cs @@ -0,0 +1,405 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; +using Udap.Support.Tests.Extensions; +using Xunit.Abstractions; +using X509Extensions = Org.BouncyCastle.Asn1.X509.X509Extensions; +// ReSharper disable All +#pragma warning disable xUnit1004 + +namespace Udap.PKI.Generator; + +/// +/// Generates a self-contained TEFCA test PKI hierarchy and client certificates. +/// +/// The local test hierarchy simulates an RCE-issued trust chain: +/// TEFCA-Test-CA → TEFCA-Test-Intermediate → Client Certificates +/// +/// Client certificates are issued with SAN URIs containing exchange purposes +/// in the path, per SOP: Facilitated FHIR Implementation v2.0 Section 6.11 Registration #5a. +/// +/// Two variants are generated: +/// - TEFCA_Community: AIA/CRL point to crl.fhircerts.net (for deployment) +/// - TEFCA_Community_Desk: AIA/CRL point to host.docker.internal:5033 (for local dev) +/// +/// SOP v2.0 — Section 6.11 +/// +public class TefcaBuild : CertificateBase +{ + private readonly ITestOutputHelper _testOutputHelper; + + public TefcaBuild(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + /// + /// Generates TEFCA PKI for deployment — AIA/CRL URLs point to crl.fhircerts.net. + /// Output directory: certstores/TEFCA_Community + /// + [Fact] + public void BuildTefcaTestPki() + { + var config = new TefcaPkiConfig + { + CertStoreName = "TEFCA_Community", + IntermediateCrlFilename = "TefcaTestIntermediateCrl.crl", + RootCrlFilename = "TefcaTestRootCrl.crl", + CrlBaseUrl = "http://crl.fhircerts.net/crl", + CaPublicCertUrl = "http://crl.fhircerts.net/certs/TEFCA_Test_CA.cer", + IntermediatePublicCertUrl = "http://crl.fhircerts.net/certs/intermediates/TEFCA_Test_Intermediate.cer", + ServerSans = new List + { + "https://fhirlabs.net/fhir/r4" + } + }; + + BuildTefcaPki(config); + } + + /// + /// Generates TEFCA PKI for local desktop development — AIA/CRL URLs point to + /// host.docker.internal:5033 (Udap.Certificates.Server). + /// Output directory: certstores/TEFCA_Community_Desk + /// + [Fact] + public void BuildTefcaTestPkiDesk() + { + var config = new TefcaPkiConfig + { + CertStoreName = "TEFCA_Community_Desk", + IntermediateCrlFilename = "TefcaTestIntermediateCrl.crl", + RootCrlFilename = "TefcaTestRootCrl.crl", + CrlBaseUrl = "http://host.docker.internal:5033/crl", + CaPublicCertUrl = "http://host.docker.internal:5033/certs/TEFCA_Test_CA.cer", + IntermediatePublicCertUrl = "http://host.docker.internal:5033/certs/intermediates/TEFCA_Test_Intermediate.cer", + ServerSans = new List + { + "https://localhost:7016/fhir/r4", + "https://localhost:7074/fhir/r4" + } + }; + + BuildTefcaPki(config, distribute: true); + } + + /// + /// Core PKI generation method parameterized by URL configuration. + /// + /// Hierarchy: + /// TEFCA-Test-CA (Root, self-signed, 4096-bit RSA) + /// └── TEFCA-Test-Intermediate (Intermediate CA, 4096-bit RSA) + /// ├── T-TRTMNT client cert (SAN: urn:oid:2.999#T-TRTMNT) + /// ├── T-IAS client cert (SAN: urn:oid:2.999#T-IAS) + /// ├── T-TREAT client cert (SAN: urn:oid:2.999#T-TREAT) + /// ├── T-PYMNT client cert (SAN: urn:oid:2.999#T-PYMNT) + /// └── server cert (SANs: fhirlabs.net, localhost:7016, localhost:7074) + /// + private void BuildTefcaPki(TefcaPkiConfig config, bool distribute = false) + { + var certStore = $"{BaseDir}/certstores/{config.CertStoreName}"; + var intermediatesDir = $"{certStore}/intermediates"; + var issuedDir = $"{certStore}/issued"; + var crlDir = $"{certStore}/crl"; + + var rootCrlHosted = $"{config.CrlBaseUrl}/{config.RootCrlFilename}"; + var intermediateCrlHosted = $"{config.CrlBaseUrl}/{config.IntermediateCrlFilename}"; + + certStore.EnsureDirectoryExists(); + intermediatesDir.EnsureDirectoryExists(); + issuedDir.EnsureDirectoryExists(); + crlDir.EnsureDirectoryExists(); + + #region TEFCA Test Root CA + + using RSA caKey = RSA.Create(4096); + using RSA intermediateKey = RSA.Create(4096); + + var caReq = new CertificateRequest( + "CN=TEFCA-Test-CA, OU=Root, O=Fhir Coding, L=Portland, S=Oregon, C=US", + caKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + caReq.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + + caReq.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign, + true)); + + caReq.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(caReq.PublicKey, false)); + + using var caCert = caReq.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(10)); + + var caBytes = caCert.Export(X509ContentType.Pkcs12, "udap-test"); + File.WriteAllBytes($"{certStore}/TEFCA_Test_CA.pfx", caBytes); + var caPem = PemEncoding.Write("CERTIFICATE", caCert.RawData); + File.WriteAllBytes($"{certStore}/TEFCA_Test_CA.cer", + caPem.Select(c => (byte)c).ToArray()); + + #endregion + + #region TEFCA Test Intermediate CA + + var intermediateReq = new CertificateRequest( + "CN=TEFCA-Test-Intermediate, OU=Intermediate, O=Fhir Coding, L=Portland, S=Oregon, C=US", + intermediateKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + intermediateReq.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + + intermediateReq.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign, + true)); + + intermediateReq.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(intermediateReq.PublicKey, false)); + + AddAuthorityKeyIdentifier(caCert, intermediateReq, _testOutputHelper); + intermediateReq.CertificateExtensions.Add(MakeCdp(rootCrlHosted)); + + var authorityInfoAccessBuilder = new AuthorityInformationAccessBuilder(); + authorityInfoAccessBuilder.AddCertificateAuthorityIssuerUri(new Uri(config.CaPublicCertUrl)); + intermediateReq.CertificateExtensions.Add(authorityInfoAccessBuilder.Build()); + + using var intermediateCert = intermediateReq.Create( + caCert, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(5), + new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); + var intermediateCertWithKey = intermediateCert.CopyWithPrivateKey(intermediateKey); + + var intermediateBytes = intermediateCertWithKey.Export(X509ContentType.Pkcs12, "udap-test"); + File.WriteAllBytes($"{intermediatesDir}/TEFCA_Test_Intermediate.pfx", intermediateBytes); + var intermediatePem = PemEncoding.Write("CERTIFICATE", intermediateCert.RawData); + File.WriteAllBytes($"{intermediatesDir}/TEFCA_Test_Intermediate.cer", + intermediatePem.Select(c => (byte)c).ToArray()); + + #endregion + + #region Initial CRLs (empty, no revocations) + + GenerateInitialCrl( + caCert, caKey, + $"{crlDir}/{config.RootCrlFilename}"); + + GenerateInitialCrl( + intermediateCertWithKey, intermediateKey, + $"{crlDir}/{config.IntermediateCrlFilename}"); + + #endregion + + #region Client Certificate with all Exchange Purposes in SAN + + // Single client cert with all XP codes plus one invalid SAN for testing + BuildTefcaClientCert( + intermediateCert, caCert, intermediateKey, + "all-xp", + new List + { + "urn:oid:2.999#T-TRTMNT", + "urn:oid:2.999#T-TREAT", + "urn:oid:2.999#T-PYMNT", + "urn:oid:2.999#T-HCO", + "urn:oid:2.999#T-HCO-CC", + "urn:oid:2.999#T-HCO-HED", + "urn:oid:2.999#T-HCO-QM", + "urn:oid:2.999#T-PH", + "urn:oid:2.999#T-PH-ECR", + "urn:oid:2.999#T-PH-ELR", + "urn:oid:2.999#T-IAS", + "urn:oid:2.999#T-GOVDTRM", + "urn:oid:2.999#INVALID" + }, + $"{issuedDir}/fhirlabs.net.tefca.client", + intermediateCrlHosted, config.IntermediatePublicCertUrl); + + #endregion + + #region Server Certificate (for signing UDAP metadata) + + BuildTefcaClientCert( + intermediateCert, caCert, intermediateKey, + "server", + config.ServerSans, + $"{issuedDir}/fhirlabs.net.tefca.server", + intermediateCrlHosted, config.IntermediatePublicCertUrl); + + #endregion + + #region Distribute to example projects + + if (distribute) + { + var fhirLabsApi = $"{BaseDir}/../../examples/FhirLabsApi"; + var authServer = $"{BaseDir}/../../examples/Udap.Auth.Server"; + var certServer = $"{BaseDir}/../../examples/Udap.Certificates.Server/wwwroot"; + + // FhirLabsApi: only the server endcert for signing UDAP metadata + $"{fhirLabsApi}/CertStore/issued".EnsureDirectoryExists(); + File.Copy($"{issuedDir}/fhirlabs.net.tefca.server.pfx", + $"{fhirLabsApi}/CertStore/issued/fhirlabs.net.tefca.server.pfx", true); + + // Udap.Auth.Server: only the server endcert for signing UDAP metadata + // (anchors and intermediates go in the database via UdapDb migrations) + $"{authServer}/CertStore/issued".EnsureDirectoryExists(); + File.Copy($"{issuedDir}/fhirlabs.net.tefca.server.pfx", + $"{authServer}/CertStore/issued/fhirlabs.net.tefca.server.pfx", true); + + // Udap.Certificates.Server: CA anchor (AIA), intermediate cert (AIA), and CRLs (CDP) + // for local desktop resolution of certificate chain + $"{certServer}/certs/intermediates".EnsureDirectoryExists(); + File.Copy($"{certStore}/TEFCA_Test_CA.cer", + $"{certServer}/certs/TEFCA_Test_CA.cer", true); + File.Copy($"{intermediatesDir}/TEFCA_Test_Intermediate.cer", + $"{certServer}/certs/intermediates/TEFCA_Test_Intermediate.cer", true); + File.Copy($"{crlDir}/{config.RootCrlFilename}", + $"{certServer}/crl/{config.RootCrlFilename}", true); + File.Copy($"{crlDir}/{config.IntermediateCrlFilename}", + $"{certServer}/crl/{config.IntermediateCrlFilename}", true); + + _testOutputHelper.WriteLine( + $"[{config.CertStoreName}] Distributed TEFCA certs to FhirLabsApi, Udap.Auth.Server, and Udap.Certificates.Server"); + } + + #endregion + } + + private X509Certificate2 BuildTefcaClientCert( + X509Certificate2 intermediateCert, + X509Certificate2 caCert, + RSA intermediateKey, + string exchangePurpose, + List subjectAltNames, + string clientCertFilePath, + string cdpUrl, + string aiaUrl) + { + var distinguishedName = + "CN=TEFCA-Mock, OU=TEFCA-TEST, O=Fhir Coding, L=Portland, S=Oregon, C=US"; + + var intermediateCertWithKey = intermediateCert.HasPrivateKey + ? intermediateCert + : intermediateCert.CopyWithPrivateKey(intermediateKey); + + using RSA rsaKey = RSA.Create(2048); + + var clientReq = new CertificateRequest( + distinguishedName, + rsaKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + clientReq.CertificateExtensions.Add( + new X509BasicConstraintsExtension(false, false, 0, true)); + + clientReq.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature, + true)); + + clientReq.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(clientReq.PublicKey, false)); + + AddAuthorityKeyIdentifier(intermediateCert, clientReq, _testOutputHelper); + clientReq.CertificateExtensions.Add(MakeCdp(cdpUrl)); + + var subAltNameBuilder = new SubjectAlternativeNameBuilder(); + foreach (var san in subjectAltNames) + { + subAltNameBuilder.AddUri(new Uri(san)); + } + clientReq.CertificateExtensions.Add(subAltNameBuilder.Build()); + + var aiaBuilder = new AuthorityInformationAccessBuilder(); + aiaBuilder.AddCertificateAuthorityIssuerUri(new Uri(aiaUrl)); + clientReq.CertificateExtensions.Add(aiaBuilder.Build()); + + var clientCert = clientReq.Create( + intermediateCertWithKey, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddYears(2), + new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); + + var clientCertWithKey = clientCert.CopyWithPrivateKey(rsaKey); + + var certPackage = new X509Certificate2Collection(); + certPackage.Add(clientCertWithKey); + certPackage.Add(new X509Certificate2(intermediateCert.Export(X509ContentType.Cert))); + certPackage.Add(new X509Certificate2(caCert.Export(X509ContentType.Cert))); + + var clientBytes = certPackage.Export(X509ContentType.Pkcs12, "udap-test"); + File.WriteAllBytes($"{clientCertFilePath}.pfx", clientBytes!); + var clientPem = PemEncoding.Write("CERTIFICATE", clientCert.RawData); + File.WriteAllBytes($"{clientCertFilePath}.cer", + clientPem.Select(c => (byte)c).ToArray()); + + _testOutputHelper.WriteLine( + $"Generated TEFCA {exchangePurpose} client cert: {clientCertFilePath}.pfx"); + + return clientCert; + } + + /// + /// Generates an initial empty CRL (no revocations) for the given CA certificate. + /// The CRL is compatible with the Udap.Pki.Cli renewal tool — it can be uploaded + /// to GCP and renewed using the update-crl command. + /// + private void GenerateInitialCrl(X509Certificate2 caCert, RSA caPrivateKey, string crlFilePath) + { + var bouncyCaCert = DotNetUtilities.FromX509Certificate(caCert); + var bouncyPrivateKey = DotNetUtilities.GetKeyPair(caPrivateKey).Private; + + var crlGen = new X509V2CrlGenerator(); + var now = DateTime.UtcNow; + crlGen.SetIssuerDN(bouncyCaCert.SubjectDN); + crlGen.SetThisUpdate(now); + crlGen.SetNextUpdate(now.AddDays(30)); + + // No revocations in initial CRL + + crlGen.AddExtension(X509Extensions.AuthorityKeyIdentifier, + false, + new AuthorityKeyIdentifierStructure(bouncyCaCert.GetPublicKey())); + + crlGen.AddExtension(X509Extensions.CrlNumber, false, new CrlNumber(BigInteger.One)); + + var crl = crlGen.Generate(new Asn1SignatureFactory("SHA256WithRSAEncryption", bouncyPrivateKey)); + File.WriteAllBytes(crlFilePath, crl.GetEncoded()); + + _testOutputHelper.WriteLine($"Generated initial CRL: {crlFilePath}"); + } + + private class TefcaPkiConfig + { + public required string CertStoreName { get; init; } + public required string IntermediateCrlFilename { get; init; } + public required string RootCrlFilename { get; init; } + public required string CrlBaseUrl { get; init; } + public required string CaPublicCertUrl { get; init; } + public required string IntermediatePublicCertUrl { get; init; } + public required List ServerSans { get; init; } + } +} diff --git a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs index 9ac9fa6e6..ee5e5fc92 100644 --- a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs +++ b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs @@ -144,7 +144,7 @@ public async Task GetCommunitiesTest() var response = await client.GetAsync("fhir/r4/.well-known/udap/communities"); response.EnsureSuccessStatusCode(); var communities = await response.Content.ReadFromJsonAsync>(); - Assert.Equal(7, communities!.Count); + Assert.Equal(8, communities!.Count); Assert.Contains("udap://fhirlabs1/", communities); Assert.Contains("udap://Provider2", communities); diff --git a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs index 0dca051a7..e9c255a14 100644 --- a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs +++ b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs @@ -12,6 +12,7 @@ using System.Net.Http.Json; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; using System.Text.RegularExpressions; using Duende.IdentityModel; using Microsoft.AspNetCore.Hosting; @@ -277,6 +278,50 @@ public async Task udap_certifications_requiredTest() Assert.Equal(new Uri("http://MyUdapCertification"), uriCertificationsRequired); } + /// + /// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 Discovery #2-3 + /// When queried with ?community=urn:oid:2.16.840.1.113883.3.7204.1.5, + /// the TEFCA community metadata MUST include the basic-app-certification URI + /// in both udap_certifications_supported and udap_certifications_required. + /// + [Fact] + public async Task TefcaCommunity_Certifications_OverrideRootDefaults() + { + var httpClient = _fixture.CreateClient(); + + var response = await httpClient.GetAsync( + "fhir/r4/.well-known/udap?community=urn:oid:2.16.840.1.113883.3.7204.1.5"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(json); + + const string basicAppCert = "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification"; + + // TEFCA community must include basic-app-certification in certifications_supported + var certSupported = json.RootElement + .GetProperty("udap_certifications_supported") + .EnumerateArray() + .Select(e => e.GetString()) + .ToList(); + + Assert.Contains(basicAppCert, certSupported); + + // TEFCA community must include basic-app-certification in certifications_required + var certRequired = json.RootElement + .GetProperty("udap_certifications_required") + .EnumerateArray() + .Select(e => e.GetString()) + .ToList(); + + Assert.Contains(basicAppCert, certRequired); + + // TEFCA community should NOT include the root-level test certifications + Assert.DoesNotContain("http://MyUdapCertification", certSupported); + Assert.DoesNotContain("http://MyUdapCertification2", certSupported); + } + [Fact] public async Task grant_types_supportedTest() { diff --git a/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs b/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs index 41e65621c..584eba190 100644 --- a/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs +++ b/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs @@ -44,13 +44,24 @@ public AuthorizationExtensionEnforcementTests(ITestOutputHelper testOutputHelper [Fact] public async Task TokenRequest_WithRequiredB2B_WithValidExtension_Succeeds() { - var pipeline = BuildPipeline(new ServerSettings - { - DefaultSystemScopes = "udap", - DefaultUserScopes = "udap", - SsraaVersion = SsraaVersion.V1_1, - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }); + var communityValidator = new TestCommunityTokenValidator( + "udap://fhirlabs.net", + new CommunityValidationRules + { + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + + var pipeline = BuildPipeline( + new ServerSettings + { + DefaultSystemScopes = "udap", + DefaultUserScopes = "udap", + SsraaVersion = SsraaVersion.V1_1 + }, + configureServices: services => + { + services.AddSingleton(communityValidator); + }); var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test"); var regResult = await RegisterClient(pipeline, clientCert); @@ -80,13 +91,24 @@ public async Task TokenRequest_WithRequiredB2B_WithValidExtension_Succeeds() [Fact] public async Task TokenRequest_WithRequiredB2B_WithoutExtension_Fails() { - var pipeline = BuildPipeline(new ServerSettings - { - DefaultSystemScopes = "udap", - DefaultUserScopes = "udap", - SsraaVersion = SsraaVersion.V1_1, - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }); + var communityValidator = new TestCommunityTokenValidator( + "udap://fhirlabs.net", + new CommunityValidationRules + { + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + + var pipeline = BuildPipeline( + new ServerSettings + { + DefaultSystemScopes = "udap", + DefaultUserScopes = "udap", + SsraaVersion = SsraaVersion.V1_1 + }, + configureServices: services => + { + services.AddSingleton(communityValidator); + }); var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test"); var regResult = await RegisterClient(pipeline, clientCert); @@ -111,13 +133,24 @@ public async Task TokenRequest_WithRequiredB2B_WithoutExtension_Fails() [Fact] public async Task TokenRequest_WithRequiredB2B_WithInvalidExtension_MissingOrganizationId_Fails() { - var pipeline = BuildPipeline(new ServerSettings - { - DefaultSystemScopes = "udap", - DefaultUserScopes = "udap", - SsraaVersion = SsraaVersion.V1_1, - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }); + var communityValidator = new TestCommunityTokenValidator( + "udap://fhirlabs.net", + new CommunityValidationRules + { + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + + var pipeline = BuildPipeline( + new ServerSettings + { + DefaultSystemScopes = "udap", + DefaultUserScopes = "udap", + SsraaVersion = SsraaVersion.V1_1 + }, + configureServices: services => + { + services.AddSingleton(communityValidator); + }); var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test"); var regResult = await RegisterClient(pipeline, clientCert); @@ -147,14 +180,14 @@ public async Task TokenRequest_WithRequiredB2B_WithInvalidExtension_MissingOrgan } [Fact] - public async Task TokenRequest_NoRequiredExtensions_WithoutExtension_Succeeds() + public async Task TokenRequest_NoCommunityValidator_WithoutExtension_Succeeds() { + // No community validator registered — no enforcement var pipeline = BuildPipeline(new ServerSettings { DefaultSystemScopes = "udap", DefaultUserScopes = "udap", - SsraaVersion = SsraaVersion.V1_1, - AuthorizationExtensionsRequired = null + SsraaVersion = SsraaVersion.V1_1 }); var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test"); @@ -175,28 +208,31 @@ public async Task TokenRequest_NoRequiredExtensions_WithoutExtension_Succeeds() } [Fact] - public async Task TokenRequest_CommunityOverride_RequiresB2B_WithoutExtension_Fails() + public async Task TokenRequest_CommunityValidator_RequiresB2B_WithoutExtension_Fails() { - var pipeline = BuildPipeline(new ServerSettings - { - DefaultSystemScopes = "udap", - DefaultUserScopes = "udap", - SsraaVersion = SsraaVersion.V1_1, - AuthorizationExtensionsRequired = null, - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://fhirlabs.net", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - } - ] - }); + var communityValidator = new TestCommunityTokenValidator( + "udap://fhirlabs.net", + new CommunityValidationRules + { + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + + var pipeline = BuildPipeline( + new ServerSettings + { + DefaultSystemScopes = "udap", + DefaultUserScopes = "udap", + SsraaVersion = SsraaVersion.V1_1 + }, + configureServices: services => + { + services.AddSingleton(communityValidator); + }); var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test"); var regResult = await RegisterClient(pipeline, clientCert); - // Token request without extension — community requires it + // Token request without extension — community validator requires it var clientRequest = AccessTokenRequestForClientCredentialsBuilder.Create( regResult.ClientId, IdentityServerPipeline.TokenEndpoint, @@ -214,24 +250,27 @@ public async Task TokenRequest_CommunityOverride_RequiresB2B_WithoutExtension_Fa } [Fact] - public async Task TokenRequest_CommunityOverride_RequiresB2B_WithValidExtension_Succeeds() + public async Task TokenRequest_CommunityValidator_RequiresB2B_WithValidExtension_Succeeds() { - var pipeline = BuildPipeline(new ServerSettings - { - DefaultSystemScopes = "udap", - DefaultUserScopes = "udap", - SsraaVersion = SsraaVersion.V1_1, - AuthorizationExtensionsRequired = null, - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://fhirlabs.net", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#TREAT"] - } - ] - }); + var communityValidator = new TestCommunityTokenValidator( + "udap://fhirlabs.net", + new CommunityValidationRules + { + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#TREAT" } + }); + + var pipeline = BuildPipeline( + new ServerSettings + { + DefaultSystemScopes = "udap", + DefaultUserScopes = "udap", + SsraaVersion = SsraaVersion.V1_1 + }, + configureServices: services => + { + services.AddSingleton(communityValidator); + }); var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test"); var regResult = await RegisterClient(pipeline, clientCert); @@ -431,4 +470,29 @@ private static async Task RegisterClient( } #endregion + + /// + /// Simple community token validator for integration tests that provides rules + /// via and always + /// returns success from . + /// + private class TestCommunityTokenValidator : ICommunityTokenValidator + { + private readonly string _community; + private readonly CommunityValidationRules _rules; + + public TestCommunityTokenValidator(string community, CommunityValidationRules rules) + { + _community = community; + _rules = rules; + } + + public bool AppliesToCommunity(string communityName) => communityName == _community; + + public CommunityValidationRules? GetValidationRules(string? grantType) => _rules; + + public Task ValidateAsync( + UdapAuthorizationExtensionValidationContext context) + => Task.FromResult(AuthorizationExtensionValidationResult.Success()); + } } diff --git a/_tests/UdapServer.Tests/UdapServer.Tests.csproj b/_tests/UdapServer.Tests/UdapServer.Tests.csproj index db7594817..67c5a2772 100644 --- a/_tests/UdapServer.Tests/UdapServer.Tests.csproj +++ b/_tests/UdapServer.Tests/UdapServer.Tests.csproj @@ -117,6 +117,8 @@ + + diff --git a/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs b/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs index 1a82edd85..3d7398741 100644 --- a/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs +++ b/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs @@ -13,12 +13,10 @@ using NSubstitute; using Udap.Model; using Udap.Model.UdapAuthenticationExtensions; -using Udap.Server.Configuration; using Udap.Server.Storage.Stores; using Udap.Server.Validation; using Udap.Server.Validation.Default; using Udap.Tefca.Model; -using UdapServer.Tests.Common; namespace UdapServer.Tests.Validators; @@ -27,12 +25,12 @@ public class DefaultUdapAuthorizationExtensionValidatorTests private readonly ILogger _logger = Substitute.For>(); - #region No Required Extensions + #region No Community Validator — No Enforcement [Fact] - public async Task NoRequiredExtensions_NoExtensionsPresent_Succeeds() + public async Task NoCommunityValidator_NoExtensionsPresent_Succeeds() { - var validator = CreateValidator(new ServerSettings()); + var validator = CreateValidator(); var context = CreateContext(); var result = await validator.ValidateAsync(context); @@ -41,9 +39,9 @@ public async Task NoRequiredExtensions_NoExtensionsPresent_Succeeds() } [Fact] - public async Task NoRequiredExtensions_ExtensionsPresent_Succeeds() + public async Task NoCommunityValidator_ExtensionsPresent_Succeeds() { - var validator = CreateValidator(new ServerSettings()); + var validator = CreateValidator(); var context = CreateContext(extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -52,23 +50,24 @@ public async Task NoRequiredExtensions_ExtensionsPresent_Succeeds() } [Fact] - public async Task NullRequiredExtensions_Succeeds() + public async Task NoCommunityValidator_AnyPOUCode_Succeeds() { - var settings = new ServerSettings { AuthorizationExtensionsRequired = null }; - var validator = CreateValidator(settings); - var context = CreateContext(); - - var result = await validator.ValidateAsync(context); + // Without a community validator, no POU validation occurs + var validator = CreateValidator(); - Assert.True(result.IsValid); - } + var b2b = new HL7B2BAuthorizationExtension + { + OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99" + }; + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#ETREAT"); + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#HPAYMT"); - [Fact] - public async Task EmptyRequiredExtensions_Succeeds() - { - var settings = new ServerSettings { AuthorizationExtensionsRequired = [] }; - var validator = CreateValidator(settings); - var context = CreateContext(); + var extensions = new Dictionary + { + [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + }; + var context = CreateContext(extensions: extensions); var result = await validator.ValidateAsync(context); @@ -77,17 +76,16 @@ public async Task EmptyRequiredExtensions_Succeeds() #endregion - #region Required Extension Presence + #region Required Extension Presence (via Community Validator) [Fact] - public async Task RequiredExtension_Missing_Fails() + public async Task CommunityValidator_RequiredExtension_Missing_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); - var context = CreateContext(); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1"); var result = await validator.ValidateAsync(context); @@ -97,14 +95,13 @@ public async Task RequiredExtension_Missing_Fails() } [Fact] - public async Task RequiredExtension_NullExtensions_Fails() + public async Task CommunityValidator_RequiredExtension_NullExtensions_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: null); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: null); var result = await validator.ValidateAsync(context); @@ -114,14 +111,13 @@ public async Task RequiredExtension_NullExtensions_Fails() } [Fact] - public async Task RequiredExtension_EmptyExtensions_Fails() + public async Task CommunityValidator_RequiredExtension_EmptyExtensions_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: new Dictionary()); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: new Dictionary()); var result = await validator.ValidateAsync(context); @@ -130,14 +126,13 @@ public async Task RequiredExtension_EmptyExtensions_Fails() } [Fact] - public async Task RequiredExtension_Present_Succeeds() + public async Task CommunityValidator_RequiredExtension_Present_Succeeds() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -145,18 +140,17 @@ public async Task RequiredExtension_Present_Succeeds() } [Fact] - public async Task MultipleRequiredExtensions_OneMissing_Fails() + public async Task CommunityValidator_MultipleRequiredExtensions_OneMissing_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [ UdapConstants.UdapAuthorizationExtensions.Hl7B2B, UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER - ] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); + ]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -166,14 +160,13 @@ public async Task MultipleRequiredExtensions_OneMissing_Fails() } [Fact] - public async Task RequiredExtension_WrongKeyPresent_Fails() + public async Task CommunityValidator_RequiredExtension_WrongKeyPresent_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -188,11 +181,10 @@ public async Task RequiredExtension_WrongKeyPresent_Fails() [Fact] public async Task B2BExtension_MissingOrganizationId_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); var b2b = new HL7B2BAuthorizationExtension { @@ -204,7 +196,7 @@ public async Task B2BExtension_MissingOrganizationId_Fails() { [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b }; - var context = CreateContext(extensions: extensions); + var context = CreateContext(communityId: "1", extensions: extensions); var result = await validator.ValidateAsync(context); @@ -215,11 +207,10 @@ public async Task B2BExtension_MissingOrganizationId_Fails() [Fact] public async Task B2BExtension_MissingPurposeOfUse_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); var b2b = new HL7B2BAuthorizationExtension { @@ -231,7 +222,7 @@ public async Task B2BExtension_MissingPurposeOfUse_Fails() { [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b }; - var context = CreateContext(extensions: extensions); + var context = CreateContext(communityId: "1", extensions: extensions); var result = await validator.ValidateAsync(context); @@ -242,11 +233,10 @@ public async Task B2BExtension_MissingPurposeOfUse_Fails() [Fact] public async Task B2BExtension_MissingVersion_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); var b2b = new HL7B2BAuthorizationExtension { @@ -259,7 +249,7 @@ public async Task B2BExtension_MissingVersion_Fails() { [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b }; - var context = CreateContext(extensions: extensions); + var context = CreateContext(communityId: "1", extensions: extensions); var result = await validator.ValidateAsync(context); @@ -270,12 +260,11 @@ public async Task B2BExtension_MissingVersion_Fails() [Fact] public async Task B2BExtension_AllRequiredFieldsPresent_Succeeds() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -285,11 +274,10 @@ public async Task B2BExtension_AllRequiredFieldsPresent_Succeeds() [Fact] public async Task B2BUserExtension_MissingUserPerson_Fails() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]); + + var validator = CreateValidator(clientStore, [communityValidator]); var b2bUser = new HL7B2BUserAuthorizationExtension(); b2bUser.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); @@ -298,7 +286,7 @@ public async Task B2BUserExtension_MissingUserPerson_Fails() { [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] = b2bUser }; - var context = CreateContext(extensions: extensions); + var context = CreateContext(communityId: "1", extensions: extensions); var result = await validator.ValidateAsync(context); @@ -309,17 +297,16 @@ public async Task B2BUserExtension_MissingUserPerson_Fails() [Fact] public async Task UnknownExtensionType_NotStructurallyValidated_Succeeds() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = ["custom-extension"] - }; - var validator = CreateValidator(settings); + var (clientStore, communityValidator) = SetupCommunityValidator( + requiredExtensions: ["custom-extension"]); + + var validator = CreateValidator(clientStore, [communityValidator]); var extensions = new Dictionary { ["custom-extension"] = new { version = "1", custom_field = "value" } }; - var context = CreateContext(extensions: extensions); + var context = CreateContext(communityId: "1", extensions: extensions); var result = await validator.ValidateAsync(context); @@ -331,26 +318,21 @@ public async Task UnknownExtensionType_NotStructurallyValidated_Succeeds() #region Community Settings [Fact] - public async Task CommunityOverride_MatchingCommunity_UsesOverrideSettings() + public async Task CommunityValidator_MatchingCommunity_UsesValidatorRules() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = null, - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - } - ] - }; + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); var context = CreateContext(communityId: "1"); var result = await validator.ValidateAsync(context); @@ -360,27 +342,22 @@ public async Task CommunityOverride_MatchingCommunity_UsesOverrideSettings() } [Fact] - public async Task CommunityOverride_MatchingCommunity_WithValidExtension_Succeeds() + public async Task CommunityValidator_MatchingCommunity_WithValidExtension_Succeeds() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = null, - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#TREAT"] - } - ] - }; + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#TREAT" } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -389,153 +366,109 @@ public async Task CommunityOverride_MatchingCommunity_WithValidExtension_Succeed } [Fact] - public async Task CommunityOverride_NonMatchingCommunity_FallsBackToGlobal() + public async Task CommunityValidator_NonMatchingCommunity_NoEnforcement() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("2").Returns(Task.FromResult("udap://community-b")); - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = null - } - ] - }; + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-b").Returns(false); - var validator = CreateValidator(settings, clientStore); - // Client is in community 2, which doesn't match any CommunitySettings + var validator = CreateValidator(clientStore, [communityValidator]); + // No extensions — passes because no validator matched var context = CreateContext(communityId: "2"); var result = await validator.ValidateAsync(context); - // Falls back to global which requires hl7-b2b - Assert.False(result.IsValid); - Assert.Contains("hl7-b2b", result.ErrorDescription); + Assert.True(result.IsValid); } [Fact] - public async Task CommunityOverride_NullCommunityId_FallsBackToGlobal() + public async Task CommunityValidator_NullCommunityId_NoEnforcement() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [] - } - ] - }; - - var validator = CreateValidator(settings); + var validator = CreateValidator(); var context = CreateContext(communityId: null); var result = await validator.ValidateAsync(context); - // Falls back to global which requires hl7-b2b - Assert.False(result.IsValid); + Assert.True(result.IsValid); } [Fact] - public async Task CommunityOverride_OverrideHasNoRequired_GlobalHasRequired_UsesOverride() + public async Task CommunityValidator_RulesOverride_EmptyRequired_Succeeds() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [] - } - ] - }; + RequiredExtensions = new HashSet() // empty — no extensions required + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); var context = CreateContext(communityId: "1"); var result = await validator.ValidateAsync(context); - // Community override has empty required list — no extensions needed Assert.True(result.IsValid); } [Fact] - public async Task CommunityOverride_NullOverrideRequired_FallsBackToGlobal() + public async Task CommunityValidator_NullRules_NoEnforcement() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = null - } - ] - }; + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns((CommunityValidationRules?)null); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); + // No extensions — passes because validator returned null rules var context = CreateContext(communityId: "1"); var result = await validator.ValidateAsync(context); - // Community override has null required — falls back to global - Assert.False(result.IsValid); - Assert.Contains("hl7-b2b", result.ErrorDescription); + Assert.True(result.IsValid); } [Fact] - public async Task CommunityOverride_MultipleCommunities_CorrectOneSelected() + public async Task CommunityValidator_MultipleCommunities_CorrectOneSelected() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); - clientStore.GetCommunityId("udap://community-b") - .Returns(Task.FromResult(2)); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + clientStore.GetCommunityName("2").Returns(Task.FromResult("udap://community-b")); - var settings = new ServerSettings + var validatorA = Substitute.For(); + validatorA.AppliesToCommunity("udap://community-a").Returns(true); + validatorA.AppliesToCommunity("udap://community-b").Returns(false); + validatorA.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = null, - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }, - new CommunityServerSettings - { - Community = "udap://community-b", - AuthorizationExtensionsRequired = - [ - UdapConstants.UdapAuthorizationExtensions.Hl7B2B, - TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS - ] - } - ] - }; + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + + var validatorB = Substitute.For(); + validatorB.AppliesToCommunity("udap://community-a").Returns(false); + validatorB.AppliesToCommunity("udap://community-b").Returns(true); + validatorB.GetValidationRules("client_credentials").Returns(new CommunityValidationRules + { + RequiredExtensions = new HashSet + { + UdapConstants.UdapAuthorizationExtensions.Hl7B2B, + TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS + } + }); + validatorB.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); // Client in community-b should need both hl7-b2b and tefca-ias - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [validatorA, validatorB]); var context = CreateContext(communityId: "2", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -545,49 +478,45 @@ public async Task CommunityOverride_MultipleCommunities_CorrectOneSelected() } [Fact] - public async Task CommunityOverride_CommunityNotFoundInStore_FallsBackToGlobal() + public async Task CommunityValidator_CommunityNameNotResolved_NoEnforcement() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(null)); - - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [] - } - ] - }; + clientStore.GetCommunityName("1").Returns(Task.FromResult(null)); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore); + // No extensions — passes because community name didn't resolve var context = CreateContext(communityId: "1"); var result = await validator.ValidateAsync(context); - // Community name didn't resolve to an ID that matches "1", falls back to global - Assert.False(result.IsValid); - Assert.Contains("hl7-b2b", result.ErrorDescription); + Assert.True(result.IsValid); } #endregion - #region Grant Type Specific Extensions + #region Grant Type Specific Extensions (via Community Validator) [Fact] public async Task GrantTypeSpecific_ClientCredentials_RequiresB2B_Succeeds() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions(), grantType: "client_credentials"); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules + { + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions(), grantType: "client_credentials"); var result = await validator.ValidateAsync(context); @@ -597,13 +526,18 @@ public async Task GrantTypeSpecific_ClientCredentials_RequiresB2B_Succeeds() [Fact] public async Task GrantTypeSpecific_ClientCredentials_MissingB2B_Fails() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); - var context = CreateContext(grantType: "client_credentials"); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", grantType: "client_credentials"); var result = await validator.ValidateAsync(context); @@ -614,12 +548,19 @@ public async Task GrantTypeSpecific_ClientCredentials_MissingB2B_Fails() [Fact] public async Task GrantTypeSpecific_AuthorizationCode_RequiresB2BUser_Succeeds() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules { - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + var validator = CreateValidator(clientStore, [communityValidator]); var b2bUser = new HL7B2BUserAuthorizationExtension(); b2bUser.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); @@ -629,7 +570,7 @@ public async Task GrantTypeSpecific_AuthorizationCode_RequiresB2BUser_Succeeds() { [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] = b2bUser }; - var context = CreateContext(extensions: extensions, grantType: "authorization_code"); + var context = CreateContext(communityId: "1", extensions: extensions, grantType: "authorization_code"); var result = await validator.ValidateAsync(context); @@ -639,13 +580,18 @@ public async Task GrantTypeSpecific_AuthorizationCode_RequiresB2BUser_Succeeds() [Fact] public async Task GrantTypeSpecific_AuthorizationCode_MissingB2BUser_Fails() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules { - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); - var context = CreateContext(grantType: "authorization_code"); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER } + }); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", grantType: "authorization_code"); var result = await validator.ValidateAsync(context); @@ -657,13 +603,18 @@ public async Task GrantTypeSpecific_AuthorizationCode_MissingB2BUser_Fails() public async Task GrantTypeSpecific_AuthorizationCode_WrongExtension_Fails() { // Client sends hl7-b2b but authorization_code requires hl7-b2b-user - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules { - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions(), grantType: "authorization_code"); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER } + }); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions(), grantType: "authorization_code"); var result = await validator.ValidateAsync(context); @@ -672,54 +623,30 @@ public async Task GrantTypeSpecific_AuthorizationCode_WrongExtension_Fails() } [Fact] - public async Task GrantTypeSpecific_SSRAA_AuthorizationCode_NoExtensionRequired_Succeeds() + public async Task GrantTypeSpecific_AuthorizationCode_NoExtensionRequired_Succeeds() { - // SSRAA requires hl7-b2b for client_credentials but NOT for authorization_code - var settings = new ServerSettings - { - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - // AuthorizationCodeExtensionsRequired is null — no extension needed - }; - var validator = CreateValidator(settings); - var context = CreateContext(grantType: "authorization_code"); // no extensions - - var result = await validator.ValidateAsync(context); - - Assert.True(result.IsValid); - } + // Community validator requires hl7-b2b for client_credentials but NOT for authorization_code + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - [Fact] - public async Task GrantTypeSpecific_FallsBackToGeneral_WhenNoGrantSpecific() - { - // Only general AuthorizationExtensionsRequired set, no grant-specific - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - }; - var validator = CreateValidator(settings); - var context = CreateContext(grantType: "client_credentials"); - - var result = await validator.ValidateAsync(context); - - Assert.False(result.IsValid); - Assert.Contains("hl7-b2b", result.ErrorDescription); - } - - [Fact] - public async Task GrantTypeSpecific_OverridesGeneral() - { - // General requires hl7-b2b, but grant-specific for client_credentials is empty - var settings = new ServerSettings + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B } + }); + communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - ClientCredentialsExtensionsRequired = [] - }; - var validator = CreateValidator(settings); - var context = CreateContext(grantType: "client_credentials"); + RequiredExtensions = null // no extension needed for auth code + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", grantType: "authorization_code"); // no extensions var result = await validator.ValidateAsync(context); - // Grant-specific empty set overrides the general requirement Assert.True(result.IsValid); } @@ -727,27 +654,19 @@ public async Task GrantTypeSpecific_OverridesGeneral() public async Task GrantTypeSpecific_Community_ClientCredentials_RequiresB2B() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5") - .Returns(Task.FromResult(10)); + clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5")); - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "urn:oid:2.16.840.1.113883.3.7204.1.5", - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER], - AllowedPurposeOfUse = - [ - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT" - ] - } - ] - }; + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT" } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); var b2b = new HL7B2BAuthorizationExtension { @@ -771,23 +690,18 @@ public async Task GrantTypeSpecific_Community_ClientCredentials_RequiresB2B() public async Task GrantTypeSpecific_Community_AuthorizationCode_RequiresB2BUser() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5") - .Returns(Task.FromResult(10)); + clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5")); - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true); + communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules { - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "urn:oid:2.16.840.1.113883.3.7204.1.5", - ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] - } - ] - }; + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); // Client sends hl7-b2b (wrong for authorization_code) var context = CreateContext(communityId: "10", extensions: CreateValidB2BExtensions(), grantType: "authorization_code"); @@ -799,49 +713,49 @@ public async Task GrantTypeSpecific_Community_AuthorizationCode_RequiresB2BUser( } [Fact] - public async Task GrantTypeSpecific_Community_FallsBackToGeneralCommunity() + public async Task GrantTypeSpecific_Community_NullGrantRules_NoEnforcement() { - // Community has AuthorizationExtensionsRequired but no grant-specific + // Community validator applies but returns null rules for the grant type — no enforcement var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - var settings = new ServerSettings - { - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] - } - ] - }; + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns((CommunityValidationRules?)null); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); + var validator = CreateValidator(clientStore, [communityValidator]); var context = CreateContext(communityId: "1", grantType: "client_credentials"); var result = await validator.ValidateAsync(context); - // Falls back to community's general AuthorizationExtensionsRequired - Assert.False(result.IsValid); - Assert.Contains("hl7-b2b", result.ErrorDescription); + // No enforcement — community validator returned null rules + Assert.True(result.IsValid); } #endregion - #region Purpose of Use Validation + #region Purpose of Use Validation (via Community Validators) [Fact] - public async Task AllowedPurposeOfUse_ValidCode_Succeeds() + public async Task CommunityValidator_AllowedPurposeOfUse_ValidCode_Succeeds() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#TREAT", "urn:oid:2.16.840.1.113883.5.8#ETREAT"] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#TREAT", "urn:oid:2.16.840.1.113883.5.8#ETREAT" } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -849,16 +763,24 @@ public async Task AllowedPurposeOfUse_ValidCode_Succeeds() } [Fact] - public async Task AllowedPurposeOfUse_DisallowedCode_Fails() + public async Task CommunityValidator_AllowedPurposeOfUse_DisallowedCode_Fails() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#ETREAT"] - }; - var validator = CreateValidator(settings); + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#ETREAT" } + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + var validator = CreateValidator(clientStore, [communityValidator]); // CreateValidB2BExtensions uses TREAT, which is not in the allowed set - var context = CreateContext(extensions: CreateValidB2BExtensions()); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -869,48 +791,23 @@ public async Task AllowedPurposeOfUse_DisallowedCode_Fails() } [Fact] - public async Task AllowedPurposeOfUse_NullAllowList_AnyCodeAccepted() + public async Task CommunityValidator_NullAllowList_AnyCodeAccepted() { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = null - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); - - var result = await validator.ValidateAsync(context); - - Assert.True(result.IsValid); - } + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); - [Fact] - public async Task AllowedPurposeOfUse_EmptyAllowList_AllCodesRejected() - { - var settings = new ServerSettings + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = [] - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); - - var result = await validator.ValidateAsync(context); - - Assert.False(result.IsValid); - Assert.Contains("disallowed", result.ErrorDescription); - } + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, + AllowedPurposeOfUse = null + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - [Fact] - public async Task MaxPurposeOfUseCount_WithinLimit_Succeeds() - { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - MaxPurposeOfUseCount = 2 - }; - var validator = CreateValidator(settings); - var context = CreateContext(extensions: CreateValidB2BExtensions()); // has 1 + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); var result = await validator.ValidateAsync(context); @@ -918,14 +815,22 @@ public async Task MaxPurposeOfUseCount_WithinLimit_Succeeds() } [Fact] - public async Task MaxPurposeOfUseCount_ExceedsLimit_Fails() + public async Task CommunityValidator_MaxPurposeOfUseCount_ExceedsLimit_Fails() { - var settings = new ServerSettings + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a")); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("udap://community-a").Returns(true); + communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], + RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }, MaxPurposeOfUseCount = 1 - }; - var validator = CreateValidator(settings); + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + var validator = CreateValidator(clientStore, [communityValidator]); var b2b = new HL7B2BAuthorizationExtension { @@ -938,7 +843,7 @@ public async Task MaxPurposeOfUseCount_ExceedsLimit_Fails() { [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b }; - var context = CreateContext(extensions: extensions); + var context = CreateContext(communityId: "1", extensions: extensions); var result = await validator.ValidateAsync(context); @@ -946,205 +851,124 @@ public async Task MaxPurposeOfUseCount_ExceedsLimit_Fails() Assert.Contains("maximum allowed is 1", result.ErrorDescription); } - [Fact] - public async Task MaxPurposeOfUseCount_Null_NoLimit() - { - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - MaxPurposeOfUseCount = null - }; - var validator = CreateValidator(settings); + #endregion - var b2b = new HL7B2BAuthorizationExtension - { - OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99" - }; - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#ETREAT"); - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#HPAYMT"); + #region ErrorExtensions - var extensions = new Dictionary + [Fact] + public void ErrorExtensions_Failure_Factory_Carries_Extensions() + { + var errorExtensions = new Dictionary { - [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + ["hl7-b2b"] = new + { + consent_required = new[] { "urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1" }, + consent_form = "https://tefca.example.com/consent/form.pdf" + } }; - var context = CreateContext(extensions: extensions); - var result = await validator.ValidateAsync(context); + var result = AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + "Consent policy required", + errorExtensions); - Assert.True(result.IsValid); + Assert.False(result.IsValid); + Assert.Equal("invalid_grant", result.Error); + Assert.Equal("Consent policy required", result.ErrorDescription); + Assert.NotNull(result.ErrorExtensions); + Assert.True(result.ErrorExtensions.ContainsKey("hl7-b2b")); } [Fact] - public async Task TefcaStyle_MaxCount1_AllowedXPCodes_ValidRequest_Succeeds() + public void ErrorExtensions_Standard_Failure_Has_Null_Extensions() { - var clientStore = Substitute.For(); - clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5") - .Returns(Task.FromResult(10)); - - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "urn:oid:2.16.840.1.113883.3.7204.1.5", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - MaxPurposeOfUseCount = 1, - AllowedPurposeOfUse = - [ - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TRTMNT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS" - ] - } - ] - }; - - var validator = CreateValidator(settings, clientStore); + var result = AuthorizationExtensionValidationResult.Failure( + "invalid_grant", + "Missing required extension"); - var b2b = new HL7B2BAuthorizationExtension - { - OrganizationId = "Organization/1.2.3", - OrganizationName = "Test QHIN" - }; - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT"); - - var extensions = new Dictionary - { - [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b - }; - var context = CreateContext(communityId: "10", extensions: extensions); + Assert.False(result.IsValid); + Assert.Null(result.ErrorExtensions); + } - var result = await validator.ValidateAsync(context); + [Fact] + public void ErrorExtensions_Success_Has_Null_Extensions() + { + var result = AuthorizationExtensionValidationResult.Success(); Assert.True(result.IsValid); + Assert.Null(result.ErrorExtensions); } + #endregion + + #region CommunityValidatorsAlwaysRun + [Fact] - public async Task TefcaStyle_MaxCount1_MultiplePurposes_Fails() + public async Task CommunityValidators_RunEvenWhenNoExtensionsRequired_AuthorizationCode() { + // Arrange: community validator returns null rules (no extensions required) + // but ValidateAsync rejects — validator should still be invoked var clientStore = Substitute.For(); - clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5") - .Returns(Task.FromResult(10)); + clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5")); - var settings = new ServerSettings - { - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "urn:oid:2.16.840.1.113883.3.7204.1.5", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - MaxPurposeOfUseCount = 1, - AllowedPurposeOfUse = - [ - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT" - ] - } - ] - }; + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true); + communityValidator.GetValidationRules("authorization_code").Returns((CommunityValidationRules?)null); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Failure( + "invalid_grant", "community validator rejected"))); - var validator = CreateValidator(settings, clientStore); - - var b2b = new HL7B2BAuthorizationExtension - { - OrganizationId = "Organization/1.2.3", - OrganizationName = "Test QHIN" - }; - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT"); - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT"); - - var extensions = new Dictionary - { - [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b - }; - var context = CreateContext(communityId: "10", extensions: extensions); + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "10", grantType: "authorization_code"); + // Act var result = await validator.ValidateAsync(context); + // Assert: community validator was invoked and its rejection is returned Assert.False(result.IsValid); - Assert.Contains("maximum allowed is 1", result.ErrorDescription); + Assert.Equal("invalid_grant", result.Error); + Assert.Contains("community validator rejected", result.ErrorDescription); } [Fact] - public async Task TefcaStyle_WrongCodeSystem_Fails() + public async Task CommunityValidators_RunEvenWhenNoExtensionsRequired_Success() { + // Arrange: community validator returns success var clientStore = Substitute.For(); - clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5") - .Returns(Task.FromResult(10)); + clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5")); - var settings = new ServerSettings - { - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "urn:oid:2.16.840.1.113883.3.7204.1.5", - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = - [ - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT" - ] - } - ] - }; + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true); + communityValidator.GetValidationRules("authorization_code").Returns((CommunityValidationRules?)null); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); - var validator = CreateValidator(settings, clientStore); - - // Client sends HL7 v3 PurposeOfUse code instead of TEFCA XP code - var b2b = new HL7B2BAuthorizationExtension - { - OrganizationId = "Organization/1.2.3", - OrganizationName = "Test QHIN" - }; - b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); - - var extensions = new Dictionary - { - [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b - }; - var context = CreateContext(communityId: "10", extensions: extensions); + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "10", grantType: "authorization_code"); + // Act var result = await validator.ValidateAsync(context); - Assert.False(result.IsValid); - Assert.Contains("disallowed", result.ErrorDescription); - Assert.Contains("2.16.840.1.113883.5.8#TREAT", result.ErrorDescription); + // Assert + Assert.True(result.IsValid); } [Fact] - public async Task CommunityOverride_PurposeOfUse_NullAllowList_FailsWithConfigError() + public async Task CommunityValidators_NotInvokedForNonMatchingCommunity() { var clientStore = Substitute.For(); - clientStore.GetCommunityId("udap://community-a") - .Returns(Task.FromResult(1)); + clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5")); - var settings = new ServerSettings - { - AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B], - AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#ETREAT"], - CommunitySettings = - [ - new CommunityServerSettings - { - Community = "udap://community-a", - AllowedPurposeOfUse = null // no fallback to global — must be configured - } - ] - }; + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(false); - var validator = CreateValidator(settings, clientStore); - var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions()); + var validator = CreateValidator(clientStore, [communityValidator]); + var context = CreateContext(communityId: "10", grantType: "authorization_code"); var result = await validator.ValidateAsync(context); - Assert.False(result.IsValid); - Assert.Equal("server_error", result.Error); - Assert.Contains("AllowedPurposeOfUse is not configured", result.ErrorDescription); + Assert.True(result.IsValid); + await communityValidator.DidNotReceive().ValidateAsync(Arg.Any()); } #endregion @@ -1152,13 +976,43 @@ public async Task CommunityOverride_PurposeOfUse_NullAllowList_FailsWithConfigEr #region Helpers private DefaultUdapAuthorizationExtensionValidator CreateValidator( - ServerSettings settings, - IUdapClientRegistrationStore? clientStore = null) + IUdapClientRegistrationStore? clientStore = null, + IEnumerable? communityValidators = null) { - var optionsMonitor = new OptionsMonitorForTests(settings); clientStore ??= Substitute.For(); - return new DefaultUdapAuthorizationExtensionValidator(optionsMonitor, clientStore, _logger); + return new DefaultUdapAuthorizationExtensionValidator( + clientStore, + communityValidators ?? Enumerable.Empty(), + _logger); + } + + /// + /// Creates a community validator mock with a matching client store for community "udap://community-a" (ID "1"). + /// + private static (IUdapClientRegistrationStore clientStore, ICommunityTokenValidator communityValidator) SetupCommunityValidator( + HashSet? requiredExtensions = null, + HashSet? allowedPurposeOfUse = null, + int? maxPurposeOfUseCount = null, + string communityName = "udap://community-a", + string communityId = "1", + string grantType = "client_credentials") + { + var clientStore = Substitute.For(); + clientStore.GetCommunityName(communityId).Returns(Task.FromResult(communityName)); + + var communityValidator = Substitute.For(); + communityValidator.AppliesToCommunity(communityName).Returns(true); + communityValidator.GetValidationRules(grantType).Returns(new CommunityValidationRules + { + RequiredExtensions = requiredExtensions, + AllowedPurposeOfUse = allowedPurposeOfUse, + MaxPurposeOfUseCount = maxPurposeOfUseCount + }); + communityValidator.ValidateAsync(Arg.Any()) + .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success())); + + return (clientStore, communityValidator); } private static UdapAuthorizationExtensionValidationContext CreateContext( diff --git a/_tests/UdapServer.Tests/Validators/SsraaCommunityValidatorTests.cs b/_tests/UdapServer.Tests/Validators/SsraaCommunityValidatorTests.cs new file mode 100644 index 000000000..931871f10 --- /dev/null +++ b/_tests/UdapServer.Tests/Validators/SsraaCommunityValidatorTests.cs @@ -0,0 +1,280 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using NSubstitute; +using Udap.Model; +using Udap.Model.UdapAuthenticationExtensions; +using Udap.Server.Storage.Stores; +using Udap.Server.Validation; +using Udap.Server.Validation.Default; +using Udap.Ssraa.Server; + +namespace UdapServer.Tests.Validators; + +public class SsraaCommunityValidatorTests +{ + private readonly ILogger _logger = + Substitute.For>(); + + #region SsraaTokenValidator Unit Tests + + [Fact] + public void AppliesToCommunity_ConfiguredCommunity_ReturnsTrue() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + Assert.True(validator.AppliesToCommunity("udap://fhirlabs.net")); + } + + [Fact] + public void AppliesToCommunity_UnconfiguredCommunity_ReturnsFalse() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + Assert.False(validator.AppliesToCommunity("udap://other-community")); + } + + [Fact] + public void GetValidationRules_ClientCredentials_RequiresHl7B2B() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + var rules = validator.GetValidationRules("client_credentials"); + + Assert.NotNull(rules); + Assert.NotNull(rules.RequiredExtensions); + Assert.Contains(UdapConstants.UdapAuthorizationExtensions.Hl7B2B, rules.RequiredExtensions); + } + + [Fact] + public void GetValidationRules_AuthorizationCode_NoExtensionsRequired() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + var rules = validator.GetValidationRules("authorization_code"); + + Assert.NotNull(rules); + Assert.Null(rules.RequiredExtensions); + } + + [Fact] + public void GetValidationRules_AllHl7V3PurposeOfUseCodesPresent() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + var rules = validator.GetValidationRules("client_credentials"); + + Assert.NotNull(rules?.AllowedPurposeOfUse); + Assert.Contains("urn:oid:2.16.840.1.113883.5.8#TREAT", rules.AllowedPurposeOfUse); + Assert.Contains("urn:oid:2.16.840.1.113883.5.8#ETREAT", rules.AllowedPurposeOfUse); + Assert.Contains("urn:oid:2.16.840.1.113883.5.8#BTG", rules.AllowedPurposeOfUse); + Assert.Contains("urn:oid:2.16.840.1.113883.5.8#HPAYMT", rules.AllowedPurposeOfUse); + Assert.Contains("urn:oid:2.16.840.1.113883.5.8#PUBHLTH", rules.AllowedPurposeOfUse); + Assert.Contains("urn:oid:2.16.840.1.113883.5.8#HMARKT", rules.AllowedPurposeOfUse); + // Should have all 62 codes + Assert.Equal(62, rules.AllowedPurposeOfUse.Count); + } + + [Fact] + public void GetValidationRules_NoMaxPurposeOfUseCount() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + var rules = validator.GetValidationRules("client_credentials"); + + Assert.NotNull(rules); + Assert.Null(rules.MaxPurposeOfUseCount); + } + + [Fact] + public void GetValidationRules_TefcaXpCode_NotInAllowedSet() + { + var validator = CreateSsraaValidator("udap://fhirlabs.net"); + + var rules = validator.GetValidationRules("client_credentials"); + + Assert.NotNull(rules?.AllowedPurposeOfUse); + // TEFCA XP codes use a different OID and should NOT be in the SSRAA allowed set + Assert.DoesNotContain("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT", rules.AllowedPurposeOfUse); + Assert.DoesNotContain("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#Joe", rules.AllowedPurposeOfUse); + } + + #endregion + + #region Integration with DefaultUdapAuthorizationExtensionValidator + + [Fact] + public async Task SsraaCommunity_TefcaXpCode_IsRejected() + { + // Reproduces: client_credentials token request to SSRAA community with + // purpose_of_use "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#Joe" + // This is a TEFCA OID, not an HL7 v3 PurposeOfUse code, and must be rejected. + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net")); + + var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net"); + var validator = CreateBaseValidator(clientStore, [ssraaValidator]); + + var b2b = new HL7B2BAuthorizationExtension + { + OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99", + OrganizationName = "FhirLabs" + }; + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#Joe"); + + var extensions = new Dictionary + { + [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + }; + var context = CreateContext(communityId: "1", extensions: extensions); + + var result = await validator.ValidateAsync(context); + + Assert.False(result.IsValid); + Assert.Equal("invalid_grant", result.Error); + Assert.Contains("disallowed", result.ErrorDescription); + Assert.Contains("Joe", result.ErrorDescription); + } + + [Fact] + public async Task SsraaCommunity_ValidHl7V3Code_Succeeds() + { + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net")); + + var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net"); + var validator = CreateBaseValidator(clientStore, [ssraaValidator]); + + var b2b = new HL7B2BAuthorizationExtension + { + OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99", + OrganizationName = "FhirLabs" + }; + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); + + var extensions = new Dictionary + { + [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + }; + var context = CreateContext(communityId: "1", extensions: extensions); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task SsraaCommunity_BogusCode_IsRejected() + { + // Any arbitrary string that isn't in the HL7 v3 value set should be rejected + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net")); + + var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net"); + var validator = CreateBaseValidator(clientStore, [ssraaValidator]); + + var b2b = new HL7B2BAuthorizationExtension + { + OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99", + OrganizationName = "FhirLabs" + }; + b2b.PurposeOfUse!.Add("TOTALLY-MADE-UP"); + + var extensions = new Dictionary + { + [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + }; + var context = CreateContext(communityId: "1", extensions: extensions); + + var result = await validator.ValidateAsync(context); + + Assert.False(result.IsValid); + Assert.Equal("invalid_grant", result.Error); + Assert.Contains("disallowed", result.ErrorDescription); + } + + [Fact] + public async Task SsraaCommunity_MultipleValidCodes_Succeeds() + { + // SSRAA does not limit purpose_of_use count (MaxPurposeOfUseCount is null) + var clientStore = Substitute.For(); + clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net")); + + var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net"); + var validator = CreateBaseValidator(clientStore, [ssraaValidator]); + + var b2b = new HL7B2BAuthorizationExtension + { + OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99", + OrganizationName = "FhirLabs" + }; + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT"); + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#ETREAT"); + b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#HPAYMT"); + + var extensions = new Dictionary + { + [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + }; + var context = CreateContext(communityId: "1", extensions: extensions); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + #endregion + + #region Helpers + + private static SsraaTokenValidator CreateSsraaValidator(params string[] communities) + { + var options = new SsraaValidationOptions(); + foreach (var c in communities) options.Communities.Add(c); + return new SsraaTokenValidator(Options.Create(options)); + } + + private DefaultUdapAuthorizationExtensionValidator CreateBaseValidator( + IUdapClientRegistrationStore clientStore, + IEnumerable communityValidators) + { + return new DefaultUdapAuthorizationExtensionValidator( + clientStore, communityValidators, _logger); + } + + private static UdapAuthorizationExtensionValidationContext CreateContext( + string? communityId = null, + Dictionary? extensions = null, + string clientId = "test-client", + string grantType = "client_credentials") + { + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken( + handler.CreateToken(new SecurityTokenDescriptor + { + Issuer = clientId, + Claims = new Dictionary { ["sub"] = clientId } + })); + + return new UdapAuthorizationExtensionValidationContext + { + ClientAssertionToken = jwt, + ClientId = clientId, + Extensions = extensions, + CommunityId = communityId, + GrantType = grantType + }; + } + + #endregion +} diff --git a/_tests/UdapServer.Tests/Validators/TefcaCommunityValidatorTests.cs b/_tests/UdapServer.Tests/Validators/TefcaCommunityValidatorTests.cs new file mode 100644 index 000000000..71419f50f --- /dev/null +++ b/_tests/UdapServer.Tests/Validators/TefcaCommunityValidatorTests.cs @@ -0,0 +1,359 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Udap.Model; +using Udap.Model.Registration; +using Udap.Model.UdapAuthenticationExtensions; +using Udap.Server.Registration; +using Udap.Server.Validation; +using Udap.Tefca.Model; +using Udap.Tefca.Server; +using Xunit; + +namespace UdapServer.Tests.Validators; + +public class TefcaCommunityValidatorTests +{ + private static readonly IOptions DefaultOptions = + Options.Create(new TefcaValidationOptions()); + #region TefcaRegistrationValidator + + [Theory] + [InlineData("T-TRTMNT")] + [InlineData("T-TREAT")] + [InlineData("T-PYMNT")] + [InlineData("T-HCO")] + [InlineData("T-HCO-CC")] + [InlineData("T-HCO-HED")] + [InlineData("T-HCO-QM")] + [InlineData("T-PH")] + [InlineData("T-PH-ECR")] + [InlineData("T-PH-ELR")] + [InlineData("T-IAS")] + [InlineData("T-GOVDTRM")] + public async Task Registration_ValidExchangePurpose_Succeeds(string xpCode) + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + var context = CreateRegistrationContext($"urn:oid:2.999#{xpCode}"); + + var result = await validator.ValidateAsync(context); + + Assert.Null(result); // null = success + } + + [Fact] + public async Task Registration_InvalidExchangePurpose_IsRejected() + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + var context = CreateRegistrationContext("urn:oid:2.999#INVALID"); + + var result = await validator.ValidateAsync(context); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("INVALID", result.ErrorDescription); + } + + [Fact] + public async Task Registration_SanUriWithoutFragment_IsRejected() + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + var context = CreateRegistrationContext("urn:oid:2.999"); + + var result = await validator.ValidateAsync(context); + + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.Contains("exchange purpose code after '#'", result.ErrorDescription); + } + + [Fact] + public async Task Registration_EmptyIssuer_IsRejected() + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + var context = CreateRegistrationContext(null); + + var result = await validator.ValidateAsync(context); + + Assert.NotNull(result); + Assert.True(result.IsError); + } + + [Fact] + public async Task Registration_TrailingHash_IsRejected() + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + var context = CreateRegistrationContext("urn:oid:2.999#"); + + var result = await validator.ValidateAsync(context); + + Assert.NotNull(result); + Assert.True(result.IsError); + } + + [Fact] + public void Registration_AppliesToTefcaCommunity() + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + + Assert.True(validator.AppliesToCommunity(TefcaConstants.CommunityUri)); + Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/")); + Assert.False(validator.AppliesToCommunity("udap://Provider2")); + } + + #endregion + + #region TefcaTokenValidator + + [Fact] + public async Task Token_MatchingPurposeOfUse_Succeeds() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-TREAT", + purposeOfUse: $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#T-TREAT"); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task Token_MatchingPurposeOfUse_BareCode_Succeeds() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-TRTMNT", + purposeOfUse: "T-TRTMNT"); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task Token_MismatchedPurposeOfUse_IsRejected() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-TREAT", + purposeOfUse: $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#T-PYMNT"); + + var result = await validator.ValidateAsync(context); + + Assert.False(result.IsValid); + Assert.Equal("invalid_grant", result.Error); + Assert.Contains("T-PYMNT", result.ErrorDescription); + Assert.Contains("T-TREAT", result.ErrorDescription); + } + + [Fact] + public async Task Token_NoRegisteredSanUri_IsRejected() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: null, + purposeOfUse: "T-TREAT"); + + var result = await validator.ValidateAsync(context); + + Assert.False(result.IsValid); + Assert.Equal("invalid_grant", result.Error); + } + + [Fact] + public async Task Token_SanUriWithoutFragment_IsRejected() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999", + purposeOfUse: "T-TREAT"); + + var result = await validator.ValidateAsync(context); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task Token_NoExtensions_Succeeds() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-TREAT", + purposeOfUse: null); // no extensions at all + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + [Fact] + public void Token_AppliesToTefcaCommunity() + { + var validator = new TefcaTokenValidator(DefaultOptions); + + Assert.True(validator.AppliesToCommunity(TefcaConstants.CommunityUri)); + Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/")); + } + + #endregion + + #region Regression — non-TEFCA communities + + [Fact] + public void Registration_NonTefcaCommunity_NotApplicable() + { + var validator = new TefcaRegistrationValidator(DefaultOptions); + + // Validator should not apply, so it would never be called + Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/")); + } + + [Fact] + public void Token_NonTefcaCommunity_NotApplicable() + { + var validator = new TefcaTokenValidator(DefaultOptions); + + Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/")); + } + + #endregion + + #region TefcaIAS conditional requirement + + [Fact] + public async Task Token_IAS_ClientCredentials_WithTefcaIas_Succeeds() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-IAS", + purposeOfUse: "T-IAS", + grantType: "client_credentials", + includeTefcaIas: true); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task Token_IAS_ClientCredentials_WithoutTefcaIas_IsRejected() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-IAS", + purposeOfUse: "T-IAS", + grantType: "client_credentials", + includeTefcaIas: false); + + var result = await validator.ValidateAsync(context); + + Assert.False(result.IsValid); + Assert.Equal("invalid_grant", result.Error); + Assert.Contains("tefca_ias", result.ErrorDescription); + } + + [Fact] + public async Task Token_IAS_AuthorizationCode_WithoutTefcaIas_Succeeds() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-IAS", + purposeOfUse: "T-IAS", + grantType: "authorization_code", + includeTefcaIas: false); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task Token_NonIAS_ClientCredentials_WithoutTefcaIas_Succeeds() + { + var validator = new TefcaTokenValidator(DefaultOptions); + var context = CreateTokenContext( + sanUri: "urn:oid:2.999#T-TREAT", + purposeOfUse: "T-TREAT", + grantType: "client_credentials", + includeTefcaIas: false); + + var result = await validator.ValidateAsync(context); + + Assert.True(result.IsValid); + } + + #endregion + + #region Helpers + + private static UdapDynamicClientRegistrationContext CreateRegistrationContext(string? issuer) + { + return new UdapDynamicClientRegistrationContext + { + Request = new UdapRegisterRequest(), + Issuer = issuer, + CommunityName = TefcaConstants.CommunityUri + }; + } + + private static UdapAuthorizationExtensionValidationContext CreateTokenContext( + string? sanUri, + string? purposeOfUse, + string grantType = "client_credentials", + bool includeTefcaIas = false) + { + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken( + handler.CreateToken(new SecurityTokenDescriptor + { + Issuer = "test-client", + Claims = new Dictionary { ["sub"] = "test-client" } + })); + + Dictionary? extensions = null; + + if (purposeOfUse != null) + { + var b2b = new HL7B2BAuthorizationExtension + { + OrganizationId = "Organization/1.2.3", + OrganizationName = "Test Org" + }; + b2b.PurposeOfUse!.Add(purposeOfUse); + + extensions = new Dictionary + { + [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b + }; + + if (includeTefcaIas) + { + extensions[TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS] = + new TEFCAIASAuthorizationExtension(); + } + } + + return new UdapAuthorizationExtensionValidationContext + { + ClientAssertionToken = jwt, + ClientId = "test-client", + Extensions = extensions, + CommunityId = "1", + CommunityName = TefcaConstants.CommunityUri, + GrantType = grantType, + SanUri = sanUri + }; + } + + #endregion +} diff --git a/docs/migration-to-openiddict.md b/docs/migration-to-openiddict.md new file mode 100644 index 000000000..41920d150 --- /dev/null +++ b/docs/migration-to-openiddict.md @@ -0,0 +1,284 @@ +# Migration Plan: Duende IdentityServer → OpenIddict + +## Overview + +This document captures the analysis and plan for migrating udap-dotnet from Duende IdentityServer to OpenIddict as the underlying OAuth 2.0 / OpenID Connect server framework. + +**Motivation**: OpenIddict is Apache 2.0 licensed with no revenue restrictions. Duende requires a paid license for companies over $1M annual revenue. + +**Note**: `Duende.IdentityModel.Client` is a free/open-source component and does not need to be replaced. + +--- + +## Integration Depth Summary + +- **177 files** (32% of codebase) reference Duende namespaces +- **462 `using Duende` statements** across the project +- Coupling is architectural — Duende provides the core OAuth2/OIDC pipeline + +### Duende NuGet Packages Currently Used (v7.4.6) + +| Project | Duende Packages | +|---|---| +| Udap.Server | `Duende.IdentityServer`, `Duende.IdentityServer.AspNetIdentity`, `Duende.IdentityServer.EntityFramework.Storage` | +| Udap.Server.Storage | `Duende.IdentityServer.EntityFramework.Storage`, `Duende.IdentityServer.Storage` | +| Udap.Auth.Server (example) | `Duende.IdentityServer`, `Duende.IdentityServer.EntityFramework` | +| Udap.Identity.Provider (examples) | `Duende.IdentityServer`, `Duende.IdentityServer.EntityFramework` | +| UdapDb.SqlServer / Postgres (migrations) | `Duende.IdentityServer.EntityFramework.Storage` | +| Udap.UI | `Duende.IdentityServer` | + +--- + +## Paradigm Difference: Interface vs Handler Pipeline + +### Duende: Interface Implementation + +Implement a well-known interface, register in DI. + +```csharp +// Duende pattern +public class UdapJwtSecretValidator : ISecretValidator +{ + public UdapJwtSecretValidator( + IIssuerNameService issuerNameService, + IReplayCache replayCache, + IServerUrls urls, + IdentityServerOptions options, + TrustChainValidator trustChainValidator, ...) + + public Task ValidateAsync( + IEnumerable secrets, ParsedSecret parsedSecret) + { + // return success/failure result + } +} + +// Registration +services.AddTransient(); +``` + +### OpenIddict: Event Handler Pipeline + +Implement a handler with a static Descriptor controlling ordering/filtering, communicate via context mutation. + +```csharp +// OpenIddict pattern +public class UdapJwtSecretHandler : IOpenIddictServerHandler +{ + public UdapJwtSecretHandler( + TrustChainValidator trustChainValidator, ...) + + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + .SetOrder(ValidateAuthentication.Descriptor.Order + 500) + .AddFilter() + .Build(); + + public ValueTask HandleAsync(ValidateTokenRequestContext context) + { + // Failure: context.Reject(error, description, uri) + // Success: just return (or set context properties) + // Full stop: context.HandleRequest() + } +} + +// Registration +services.AddOpenIddict() + .AddServer(options => + { + options.AddEventHandler(UdapJwtSecretHandler.Descriptor); + }); +``` + +### Key Differences + +| Aspect | Duende (Interface) | OpenIddict (Handler Pipeline) | +|---|---|---| +| **Contract** | Specific interfaces (`ISecretValidator`, `ISecretParser`, etc.) with typed return values | Always `IOpenIddictServerHandler` with `ValueTask HandleAsync(TContext)` | +| **Result** | Return a result object (`SecretValidationResult`) | Mutate the context (`context.Reject(...)` or set properties) | +| **Ordering** | Duende controls when your implementation is called | You control ordering via `SetOrder(int)` | +| **Composition** | One `ISecretValidator` replaces the default | Multiple handlers run in sequence — add alongside built-ins | +| **Scope** | Each interface has a narrow, specific purpose | One pattern fits all — same `HandleAsync` for auth, tokens, discovery, etc. | + +--- + +## Components to Migrate + +### Unaffected (No Duende Dependency) + +- **Udap.Model** — data models, zero external dependencies +- **Udap.Common** — certificate/trust chain validation, independent of auth server +- **Udap.Metadata.Server** — minimal Duende coupling + +### Minimal Changes + +- **Udap.Client** — uses `Duende.IdentityModel.Client` which is free/open-source. No migration needed for this dependency. + +### Major Migration Required + +#### 1. Udap.Server — Custom Duende Interface Implementations + +| Component | Duende Interface | Migration Notes | +|---|---|---| +| `UdapJwtBearerClientAssertionSecretParser` | `ISecretParser` | Rewrite as OpenIddict handler for `ExtractTokenRequestContext` or `ProcessAuthenticationContext` | +| `UdapJwtSecretValidator` | `ISecretValidator` | Rewrite as OpenIddict handler for `ValidateTokenRequestContext` | +| `UdapCustomTokenRequestValidator` | `ICustomTokenRequestValidator` | Rewrite as OpenIddict handler — hl7-b2b/TEFCA extension enforcement | +| `UdapTokenResponseGenerator` | Extends `TokenResponseGenerator` | No base class equivalent in OpenIddict — rewrite as `HandleTokenRequestContext` handler | +| `UdapInMemoryResourceStore` | `IResourceStore` | Replace with `IOpenIddictScopeManager` — scope expansion logic stays | +| `UdapDiscoveryEndpoint` | Registered as Duende `Endpoint` | Rewrite as ASP.NET Core middleware or controller | + +#### 2. Udap.Server — Middleware (Duende Service Dependencies) + +| Component | Duende Dependencies | Migration Notes | +|---|---|---| +| `UdapAuthorizationResponseMiddleware` | `IClientStore`, `IIdentityServerInteractionService` | Replace with `IOpenIddictApplicationManager` + ASP.NET Core equivalents | +| `UdapScopeEnrichmentMiddleware` | Duende scope handling | Adapt to OpenIddict scope model | +| `UdapTokenResponseMiddleware` | Duende token endpoint | Adapt to OpenIddict token pipeline | + +#### 3. Udap.Server — Dynamic Client Registration (DCR) + +The DCR is custom-built (not Duende's DCR). Key dependencies to replace: + +| Dependency | Duende Source | OpenIddict Equivalent | +|---|---|---| +| `IResourceStore` | Duende | `IOpenIddictScopeManager` | +| `IReplayCache` | Duende | Custom implementation or use OpenIddict token storage | +| `IScopeExpander` | Custom (uses `IResourceStore`) | Refactor to use `IOpenIddictScopeManager` | + +Core registration logic (JWT validation, certificate chain validation, UDAP spec compliance) is **your own code** and transfers directly. + +#### 4. Udap.Server — Tiered OAuth + +| Component | Duende Dependencies | Migration Notes | +|---|---|---| +| `TieredOAuthHelpers` | `IIdentityServerInteractionService`, `IUdapClient` | Replace interaction service with OpenIddict equivalents | +| `TieredOAuthAuthenticationHandler` | Duende external auth integration | Adapt to OpenIddict's external auth model | + +#### 5. Udap.Server — DI/Configuration + +| Component | Notes | +|---|---| +| `UdapServerServiceCollectionExtensions` | Rewrite `AddUdapServer()` to use `services.AddOpenIddict()` builder pattern | +| `UdapBuilderExtensions/UdapCore` | `AddUdapDiscovery()`, `AddUdapResponseGenerators()`, etc. — rewrite for OpenIddict | +| `Additional.cs` | `AddUdapJwtBearerClientAuthentication()` — register OpenIddict handlers instead of Duende interfaces | + +#### 6. Udap.Server.Storage — EF Core / Data Layer + +**Schema change**: Duende uses `ConfigurationDbContext` + `PersistedGrantDbContext` with Client/Scope/Grant entities. OpenIddict uses 4 tables: + +| OpenIddict Table | Purpose | +|---|---| +| `OpenIddictApplications` | Client registration (replaces Duende `Client` entity) | +| `OpenIddictScopes` | Scope definitions | +| `OpenIddictAuthorizations` | Authorization grants | +| `OpenIddictTokens` | Token storage (access, refresh, etc.) | + +Key changes: +- `UdapClientRegistrationStore` — currently maps to/from `Duende.IdentityServer.EntityFramework.Entities.Client`. Must be rewritten to use `IOpenIddictApplicationManager`. +- `UdapDbContext` — currently includes `DbSet`, `DbSet`, `DbSet`. Replace with OpenIddict entity sets. +- All mappers (`ClientExtensions.cs`) need rewriting for OpenIddict model. +- UDAP-specific entities (Anchor, Intermediate, Community, Certification, TieredClient) are **unaffected** — these are your own tables. + +#### 7. Database Migrations + +Both `UdapDb.SqlServer` and `UdapDb.Postgres` need new migrations to: +- Drop Duende tables (Client, ClientSecret, ClientScope, ApiResource, ApiScope, PersistedGrant, DeviceFlowCodes, etc.) +- Add OpenIddict tables (Applications, Scopes, Authorizations, Tokens) +- Preserve UDAP-specific tables + +#### 8. Example Projects + +All example auth servers need their startup rewritten: + +| Project | Current Pattern | New Pattern | +|---|---|---| +| `Udap.Auth.Server` | `AddIdentityServer()` + `AddUdapServer()` | `AddOpenIddict()` + `AddUdapServer()` (refactored) | +| `Udap.Identity.Provider` | `AddIdentityServer()` + `AddUdapServerAsIdentityProvider()` | `AddOpenIddict()` + equivalent | +| `Udap.Identity.Provider.2` | Same as above | Same as above | + +#### 9. Test Infrastructure + +- `UdapAuthServerPipeline` is built around Duende's test infrastructure — needs rewriting +- Integration tests that configure `IdentityServerOptions` need updating +- Test projects referencing `Duende.IdentityServer.Test` need alternatives + +#### 10. Udap.UI + +- References `Duende.IdentityServer` for login/logout/consent flows +- Needs adaptation for OpenIddict's approach to user interaction + +--- + +## Duende Service → OpenIddict Equivalent Mapping + +| Duende Service | OpenIddict Equivalent | +|---|---| +| `IClientStore` | `IOpenIddictApplicationManager` | +| `IResourceStore` | `IOpenIddictScopeManager` | +| `ISecretParser` | `IOpenIddictServerHandler` or `ProcessAuthenticationContext` handler | +| `ISecretValidator` | `IOpenIddictServerHandler` handler | +| `ICustomTokenRequestValidator` | `IOpenIddictServerHandler` handler | +| `ITokenService` | `IOpenIddictTokenManager` | +| `IProfileService` | `IOpenIddictServerHandler` handler or claims transformation | +| `IIdentityServerInteractionService` | No direct equivalent — use ASP.NET Core auth + `IOpenIddictAuthorizationManager` | +| `IIssuerNameService` | `OpenIddictServerOptions.Issuer` or `IHttpContextAccessor` | +| `IReplayCache` | Custom implementation (or leverage token storage) | +| `IServerUrls` | `IHttpContextAccessor` directly | +| `IdentityServerOptions` | `OpenIddictServerOptions` | +| `IScopeParser` | `IOpenIddictScopeManager` | +| `IRefreshTokenService` | Handled internally by OpenIddict | +| `TokenResponseGenerator` | `IOpenIddictServerHandler` + `ApplyTokenResponseContext` handlers | +| `Endpoint` (Duende endpoint registration) | ASP.NET Core middleware, controllers, or minimal APIs | + +--- + +## What Transfers Directly (No Rewrite) + +- All certificate/trust chain validation logic (`TrustChainValidator`, `ICertificateStore`, `ITrustAnchorStore`) +- UDAP JWT assertion parsing and validation (business logic) +- Authorization extension validation (hl7-b2b, TEFCA) +- Scope expansion logic (SMART v2) +- All UDAP model types +- UDAP-specific EF entities (Anchor, Intermediate, Community, etc.) +- Client-side UDAP operations (`Udap.Client`) + +--- + +## Suggested Migration Order + +1. **Udap.Server.Storage** — Swap EF entities and stores from Duende to OpenIddict models. This is foundational. +2. **Udap.Server Core** — Rewrite validators/parsers as OpenIddict handlers. Start with `UdapJwtSecretValidator` as a proof of concept. +3. **Udap.Server DCR** — Refactor `UdapDynamicClientRegistrationValidator` to use OpenIddict managers instead of Duende stores. +4. **Udap.Server DI** — Rewrite `AddUdapServer()` extension methods for OpenIddict builder pattern. +5. **Udap.Server Middleware** — Adapt authorization response, scope enrichment, and token response middleware. +6. **Tiered OAuth** — Adapt federated auth flow. +7. **Example Projects** — Update startup configuration. +8. **Database Migrations** — Generate new migrations for SQL Server and PostgreSQL. +9. **Tests** — Rebuild test pipeline and verify all existing test scenarios pass. +10. **Udap.UI** — Adapt login/logout/consent UI flows. + +--- + +## Risk Areas + +- **No built-in DCR in OpenIddict** — The custom DCR endpoint must be fully self-hosted (already the case, but Duende infrastructure won't be there as fallback) +- **IIdentityServerInteractionService** — Used in multiple places for error handling and auth flows; no single OpenIddict equivalent +- **Test infrastructure** — Significant test rewrite needed; risk of regression +- **Schema migration** — Existing deployments need data migration from Duende tables to OpenIddict tables + +--- + +## OpenIddict Source Reference + +Local source repositories available at `C:\Source\GitHub\openiddict\`: +- `openiddict-core` — Main implementation +- `openiddict-samples` — Reference implementations +- `openiddict-documentation` — Guides and configuration docs + +Key source paths: +- `openiddict-core/src/OpenIddict.Abstractions/` — Interfaces and DTOs +- `openiddict-core/src/OpenIddict.Server/` — Server event handlers and pipeline +- `openiddict-core/src/OpenIddict.Server.AspNetCore/` — ASP.NET Core integration +- `openiddict-core/src/OpenIddict.EntityFrameworkCore/` — EF Core stores +- `openiddict-core/src/OpenIddict.EntityFrameworkCore.Models/` — Database entities diff --git a/docs/skills/fhir-ig-to-ai/SKILL.md b/docs/skills/fhir-ig-to-ai/SKILL.md new file mode 100644 index 000000000..827c81a1f --- /dev/null +++ b/docs/skills/fhir-ig-to-ai/SKILL.md @@ -0,0 +1,147 @@ +--- +name: fhir-ig-to-ai +description: Convert a FHIR Implementation Guide full zip download into a single AI-friendly markdown document +argument-hint: [output-directory] +--- + +# Convert FHIR IG to AI-Friendly Markdown + +Convert the FHIR Implementation Guide zip file at `$ARGUMENTS` into a single, consolidated, AI-optimized markdown document. + +If only one argument is provided, it is the zip file path and the output will be placed in the same directory as the zip file. If two arguments are provided, the second is the output directory. + +## Step 1: Extract and Discover + +1. Extract the zip file to a temporary directory (`_ig_extract_temp/` next to the zip) +2. Look for `site/ai.zip` inside (the IG publisher generates this) — if found, extract it to `_ig_extract_temp/ai/` +3. If no `ai.zip`, fall back to collecting `.md` files from `site/` +4. Look for the ImplementationGuide JSON resource to extract metadata: + - Check for `ImplementationGuide-*.json` in the site root + - Or parse `site/index.md` or `site/index.html` for the IG metadata +5. Extract these metadata fields: + - **IG Title** (e.g., "Recognized Coordinating Entity (RCE) Implementation Guide") + - **Package ID** (e.g., `sequoia.fhir.us.rce`) + - **Version** (e.g., `1.12.0`) + - **FHIR Version** (e.g., `4.0.1`) + - **Status** (e.g., `active`) + - **Publisher** + - **Official URL** + - **Dependencies** (other IGs it depends on) + +## Step 2: Read and Categorize Content + +Read all the markdown files and categorize them: + +- **Narrative pages**: `index.md`, specification pages, change logs — these contain the human-readable guidance +- **StructureDefinition profiles**: Files named `StructureDefinition-*.md` — contain profile constraints as prose + JSON +- **OperationDefinition**: Files named `OperationDefinition-*.md` — custom operations +- **Example instances**: Files named with resource types like `Organization-*.md`, `Endpoint-*.md`, `Patient-*.md`, etc. — example resource instances +- **CodeSystem/ValueSet**: Files describing terminologies +- **CapabilityStatement**: Files describing server capabilities +- **Other artifacts**: SearchParameters, ConceptMaps, etc. + +## Step 3: Build the Consolidated Document + +Create a SINGLE markdown file with this structure. The goal is to be **information-dense but readable** for an AI model consuming it in a context window: + +### Output Filename Convention + +Use this pattern: `{short-ig-name}-v{version}-ai-friendly.md` + +- Derive the short name from the package ID (last segment) or IG title +- Examples: `rce-ig-v1.12.0-ai-friendly.md`, `us-core-v6.1.0-ai-friendly.md`, `udap-security-v1.1.0-ai-friendly.md` + +### Document Structure + +```markdown +# {IG Title} v{Version} + +> **Package**: `{packageId}#{version}` +> **FHIR Version**: {fhirVersion} +> **Status**: {status} as of {date} +> **Publisher**: {publisher} +> **Official URL**: {url} + +--- + +## 1. Overview +{Content from index.md / introduction — the narrative overview, stripped of navigation boilerplate} + +### Dependencies +{Table of IG dependencies with package IDs and versions} + +## 2. Specification +{Content from specification/guidance pages — the normative text about actors, API details, general guidance} +{Strip navigation boilerplate like TOC links, "Formal Views" references, CSV/Excel/Schematron links} + +## 3. Resource Profiles +{For each StructureDefinition profile:} +### 3.N {Profile Title} +| | | +|---|---| +| **URL** | `{canonical URL}` | +| **Base** | `{base profile name}` | + +{Description paragraph} + +**Key Constraints:** +{Table of invariants with ID, severity, human-readable description} + +**Key Element Requirements:** +{Bullet list of important differential elements with cardinalities, must-support flags, bindings, fixed values} +{Focus on WHAT IS DIFFERENT from the base — the differential, not the full snapshot} + +{Do NOT include the full raw JSON StructureDefinition — it's too verbose for AI consumption. +DO preserve: constraint expressions, binding strengths/valuesets, cardinality changes, pattern values, slicing details} + +## 4. Extensions +{Table of extensions with name, used-on, and description} +{If extensions are defined externally, note where they come from} + +## 5. Terminology +{Tables of CodeSystems and ValueSets with URLs and key codes} + +## 6. Operations +{For each OperationDefinition: name, URL, scope, description, parameters (in/out)} + +## 7. Capability Statements +{If present: server/client capabilities} + +## 8. Examples +{Include 1-2 representative examples per profile in full JSON} +{For large sets of similar examples (like 16 Organization examples), include just the most illustrative ones +and summarize the rest in prose or ASCII diagrams showing the relationships} + +## 9. Profile Inheritance Chain +{ASCII diagram showing the inheritance hierarchy} + +## 10. Change Log +{Recent changes in compact table format — date, version, summary} +``` + +### Consolidation Rules + +1. **Strip navigation boilerplate**: Remove TOC links, "Table of Contents" bullet points, breadcrumbs, "Formal Views of Profile Content" sections, links to CSV/Excel/Schematron representations +2. **Strip verbose metadata**: Remove FHIR mapping blocks (v2, RIM, ServD, FiveWs), publisher contact details from JSON, `date` fields in JSON +3. **Distill StructureDefinitions**: Convert raw JSON differentials into human-readable tables/bullet lists. Preserve the semantic content (constraints, cardinalities, bindings, patterns) but not the JSON boilerplate +4. **Deduplicate examples**: If many examples follow the same pattern, include 1-2 representative ones and describe the rest +5. **Add visual aids**: Create ASCII hierarchy diagrams for profile inheritance and organizational relationships where helpful +6. **Preserve all normative content**: Constraint expressions (FHIRPath), binding strengths, cardinality changes, invariant rules, API details, search parameters — these must be kept accurately +7. **Keep canonical URLs**: All StructureDefinition, ValueSet, CodeSystem, and Extension URLs must be preserved exactly as they appear + +## Step 4: Write Output and Clean Up + +1. Write the consolidated markdown file to the output directory using the naming convention above +2. Remove the temporary extraction directory (`_ig_extract_temp/`) +3. Report to the user: + - The output file path and size + - How many source files were consolidated + - A brief summary of what the IG covers + +## Important Notes + +- This is a FHIR IG conversion tool — the input is always a zip file downloaded from a FHIR IG publication +- The IG publisher (by HL7) generates a standard directory structure, so the patterns above should work for most IGs +- If the zip doesn't contain `site/` at the top level, look for the content directly in the root +- CRLF line endings must be preserved (this is a Windows repository) +- Do not create any files other than the single output markdown diff --git a/docs/specifications/SSRAA/V2.0.0/udap-security-v2.0.0-ai-friendly.md b/docs/specifications/SSRAA/V2.0.0/udap-security-v2.0.0-ai-friendly.md new file mode 100644 index 000000000..386fa56fd --- /dev/null +++ b/docs/specifications/SSRAA/V2.0.0/udap-security-v2.0.0-ai-friendly.md @@ -0,0 +1,984 @@ +# Security for Scalable Registration, Authentication, and Authorization (SSRAA) v2.0.0 + +| Metadata | Value | +| :--- | :--- | +| Package | hl7.fhir.us.udap-security#2.0.0 | +| FHIR Version | 4.0.1 | +| IG Status | Active (Trial-use, Maturity Level 4) | +| Date | 2025-07-28 | +| Publisher | HL7 International / Security | +| URL | http://hl7.org/fhir/us/udap-security/ImplementationGuide/hl7.fhir.us.udap-security | + +# Home - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Home + + +This Security FHIR® IG has been established upon the recommendations of ONC’s FHIR at Scale Taskforce (FAST) Security Tiger Team, and has been adapted from IGs previously published by UDAP.org. This IG is based on the UDAP STU1 profiles. The workflows defined in the Unified Data Access Profiles (UDAP™) have been used in several FHIR IGs, including the TEFCA Facilitated FHIR IG, Carequality FHIR IG, Carin BB IG, DaVinci HREX IG, and others. The objective of this IG is to harmonize workflows for both consumer-facing and B2B applications to facilitate cross-organizational and cross-network interoperability. + +Additional enhancements include a formal definition for a B2B Authorization Extension Object to facilitate these transactions. + +### Introduction + +This implementation guide describes how to extend OAuth 2.0 using UDAP workflows for both consumer-facing apps that implement the authorization code flow, and business-to-business (B2B) apps that implement the client credentials flow or authorization code flow. This guide covers automating the client application registration process and increasing security using asymmetric cryptographic keys bound to digital certificates to authenticate ecosystem participants. This guide also provides a grammar for communicating metadata critical to healthcare information exchange. + +The requirements described in this guide are intended to align with the proposed solutions of the ONC FHIR at Scale Taskforce’s Security Tiger Team, the security model and UDAP workflows outlined in the [Carequality FHIR-Based Exchange IG](https://carequality.org/wp-content/uploads/2020/12/Carequality-FHIR-Implementation-Guide.pdf), and implementation guides incorporating UDAP workflows published by the [CARIN Alliance](http://hl7.org/fhir/us/carin-bb/STU1/Authorization_Authentication_and_Registration.html#authorization-and-authentication) and the [Da Vinci Project](http://hl7.org/fhir/us/davinci-hrex/STU1/smart-app-reg.html). + +This Guide is divided into several pages which are listed at the top of each page in the menu bar. + +* [Home](index.md): The home page provides the introduction and background for this project, and general requirements that apply to all workflows described in this guide. +* [Discovery](discovery.md): This page describes how clients can discover server support for the workflows described in this guide. +* [Registration](registration.md): This page describes workflows for dynamic registration of client applications. +* [Consumer-Facing](consumer.md): This page provides detailed guidance for authorization and authentication of consumer-facing apps. +* [Business-to-Business](b2b.md): This page provides detailed guidance for authorization and authentication of B2B apps. +* [Tiered OAuth for User Authentication](user.md): This page provides detailed guidance for user authentication. +* [General Requirements](general.md): This page provides general requirements applicable to multiple authorization and authentication workflows. +* [FHIR Artifacts](artifacts.md): This page provides additional conformance artifacts for FHIR resources. + +Guidance regarding the use of this IG with the SMART App Launch Framework can be found in [Section 7.5](general.md#using-this-guide-with-the-smart-app-launch-framework). + +### Trust Community Checklist + +A trust community is a group of member entities that has come together to support one or more interoperability use cases between its members. Typical examples of trust communities include health information networks and interoperability frameworks (e.g. a network of networks). Trust communities may be defined by geography (e.g. regional, national, international) or any other criteria (e.g. affiliated healthcare organizations, users of a common technology platform, support of specific use cases required by regulation, etc.). + +This guide provides a technical framework to securely scale FHIR transactions within a trust community using X.509 certificates as the basis of community trust, including both consumer-facing and B2B workflows. It is expected that trust communities will establish agreements and policies to address certain community-specific parameters in this guide, as well as non-technical aspects necessary for interoperability, e.g. criteria for community membership, data-sharing agreements, certificate issuance policies, etc. + +Additional topics to be addressed by trust communities adopting this guide are listed below: + +1. Assignment of unique URIs to servers for use in certificates and in the`iss`and`sub`claims of signed metadata elements (see[Section 2.3](discovery.md#signed-metadata-elements)). +1. URI used to identify the community in metadata requests (see[Section 2.4](discovery.md#multiple-trust-communities)). +1. Assignment of unique URIs to client applications for use in certificates and in the`iss`and`sub`claims of software statements (see[Section 3.1](registration.md#software-statement)). +1. Assignment of unique URIs to organizational requestors for use in a B2B Authorization Extension Object (see`organization_id`in[Section 5.2.1.1](b2b.md#b2b-authorization-extension-object)). +1. Minimum set of scopes that servers must support (see`scopes_supported`in[Section 2.2](discovery.md#required-udap-metadata)). +1. Allowed values for requestor roles in a B2B Authorization Extension Object (see`subject_role`in[Section 5.2.1.1](b2b.md#b2b-authorization-extension-object)). +1. Permitted purposes of use for which data may be requested in a B2B Authorization Extension Object (see`purpose_of_use`in[Section 5.2.1.1](b2b.md#b2b-authorization-extension-object)). +1. Consent and authorization policies that may be asserted in a B2B Authorization Extension Object and supporting documentation (see`consent_policy`and`consent_reference`in[Section 5.2.1.1](b2b.md#b2b-authorization-extension-object)). +1. Time synchronization between community participants. +1. PKI policies including policies for certificate issuance and distribution. +1. Other community policies or conditions that an actor may need to meet before exchanging data with community participants or with other trust communities. Examples include community legal agreements, certificate policies, policies regarding what claims an actor has the authority to assert, and other community requirements relating to the specific use cases, client types and/or grant types supported by the community. + +--- + +# Discovery - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Discovery + +The requirements in this section are applicable to both consumer-facing and B2B apps and the servers that support them. The client and the server **SHALL** conform to the underlying server metadata profile in [UDAP Server Metadata](https://www.udap.org/udap-server-metadata-stu1.html). + +### Discovery of Endpoints + +A FHIR Server **SHALL** make its Authorization Server’s authorization, token, and registration endpoints, and associated metadata available for discovery by client applications. Servers **SHALL** respond to `GET` requests to the following metadata URL by unregistered client applications and without requiring client authentication, where {baseURL} represents the base FHIR URL for the FHIR server: {baseURL}/.well-known/udap + +The discovery workflow is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant S as FHIR Server + C->>S: Discovery request (GET {baseURL}/.well-known/udap) + S-->>C: Discovery response (JSON with UDAP metadata) +``` + +UDAP metadata **SHALL** be structured as a JSON object as per Section 1 of [UDAP Server Metadata](https://www.udap.org/udap-server-metadata-stu1.html#section-1) and discussed further in [Section 2.2](discovery.md#required-udap-metadata). + +If a server returns a `404 Not Found` response to a `GET` request to the UDAP metadata endpoint, the client application **SHOULD** conclude that the server does not support UDAP workflows. + +### Required UDAP Metadata + +The metadata returned from the UDAP metadata endpoint defined above **SHALL** represent the server’s capabilities with respect to the UDAP workflows described in this guide. If no UDAP workflows are supported, the server **SHALL** return a `404 Not Found` response to the metadata request. For elements that are represented by JSON arrays, clients **SHALL** interpret an empty array value to mean that the corresponding capability is NOT supported by the server. + +| | | | +| :--- | :--- | :--- | +| `udap_versions_supported` | required | A fixed array with one string element:`["1"]` | +| `udap_profiles_supported` | required | An array of two or more strings identifying the core UDAP profiles supported by the Authorization Server. The array**SHALL**include:`"udap_dcr"`for UDAP Dynamic Client Registration, and`"udap_authn"`for UDAP JWT-Based Client Authentication.If the`grant_types_supported`parameter includes the string`"client_credentials"`, then the array**SHALL**also include:`"udap_authz"`for UDAP Client Authorization Grants using JSON Web Tokens to indicate support for Authorization Extension Objects.If the server supports the user authentication workflow described in[Section 6](user.md#tiered-oauth-for-user-authentication), then the array**SHALL**also include:`"udap_to"`for UDAP Tiered OAuth for User Authentication. | +| `udap_authorization_extensions_supported` | required | An array of zero or more recognized key names for Authorization Extension Objects supported by the Authorization Server. If the Authorization Server supports the B2B Authorization Extension Object defined in[Section 5.2.1.1](b2b.md#b2b-authorization-extension-object), then the following key name**SHALL**be included:`["hl7-b2b"]` | +| `udap_authorization_extensions_required` | conditional | An array of zero or more recognized key names for Authorization Extension Objects required by the Authorization Server in every token request. The array**SHALL NOT**include key names that are not also elements of the`udap_authorization_extensions_supported`array. This metadata parameter**SHALL**be present if the value of the`udap_authorization_extensions_supported`parameter is not an empty array. If the Authorization Server requires the B2B Authorization Extension Object defined in[Section 5.2.1.1](b2b.md#b2b-authorization-extension-object)in every token request, then the following key name**SHALL**be included:`["hl7-b2b"]` | +| `udap_certifications_supported` | required | An array of zero or more certification URIs supported by the Authorization Server, e.g.:`["https://www.example.com/udap/profiles/example-certification"]` | +| `udap_certifications_required` | conditional | An array of zero or more certification URIs required by the Authorization Server. The array**SHALL NOT**include certification URIs that are not also elements of the`udap_certifications_supported`array. This metadata parameter**SHALL**be present if the value of the`udap_certifications_supported`parameter is not an empty array. Example:`["https://www.example.com/udap/profiles/example-certification"]` | +| `grant_types_supported` | required | An array of one or more grant types supported by the Authorization Server, e.g.:`["authorization_code", "refresh_token",  "client_credentials"]`The`"refresh_token"`grant type**SHALL**only be included if the`"authorization_code"`grant type is also included. | +| `scopes_supported` | required | An array of one or more strings containing scopes supported by the Authorization Server. The server**MAY**grant different subsets of these scopes for different client types or entities. Example for a server that also supports SMART App Launch v1 scopes:`["openid", "launch/patient", "system/Patient.read", "system/AllergyIntolerance.read", "system/Procedures.read"]` | +| `authorization_endpoint` | conditional | A string containing the absolute URL of the Authorization Server's authorization endpoint. This parameter**SHALL**be present if the value of the`grant_types_supported`parameter includes the string`"authorization_code"` | +| `token_endpoint` | required | A string containing the absolute URL of the Authorization Server's token endpoint for UDAP JWT-Based Client Authentication. | +| `token_endpoint_auth_methods_supported` | required | Fixed array with one value:`["private_key_jwt"]` | +| `token_endpoint_auth_signing_alg_values_supported` | required | Array of strings identifying one or more signature algorithms supported by the Authorization Server for validation of signed JWTs submitted to the token endpoint for client authentication. For example:`["RS256", "ES384"]` | +| `registration_endpoint` | required | A string containing the absolute URL of the Authorization Server's registration endpoint. | +| `registration_endpoint_jwt_signing_alg_values_supported` | required | Array of strings identifying one or more signature algorithms supported by the Authorization Server for validation of signed software statements, certifications, and endorsements submitted to the registration endpoint. For example:`["RS256", "ES384"]` | +| `signed_metadata` | required | A string containing a JWT listing the server's endpoints, as defined in[Section 2.3](#signed-metadata-elements)below. | + +FHIR servers with the same `registration_endpoint` URL belong to the same logical group. A registration for any FHIR server in this logical group registers the client application for all endpoints in that group. + +The above paragraph is informational and reflects deployment models that may be encountered in the real world. This guide does not currently expect or require client applications to track registration endpoints or logical groups when determining whether or not registration is necessary, nor does it expect registration endpoint URLs published by servers to be static. Implementers should be aware of the possible side effects when different registration parameters are requested for two FHIR servers in the same logical group, i.e. the second registration at the same registration endpoint URL will modify the first registration as discussed in [Section 3.4](registration.md#modifying-and-cancelling-registrations) of this guide. Implementer feedback is requested to determine if additional client and/or server requirements relating to this topic should be added in the future. + +A FHIR Server **MAY** include additional metadata elements in its metadata response as described in [UDAP Server Metadata](https://www.udap.org/udap-server-metadata-stu1.html). However, a conforming client application might not support additional metadata elements. + +### Signed metadata elements + +A server’s UDAP metadata **SHALL** include the `signed_metadata` element. The value of this element is a JWT constructed as described in [Section 7.1](general.md#json-web-token-jwt-requirements) and containing the claims in the table below. This JWT **SHALL** be signed using the [RS256](general.md#signature-algorithm-identifiers) signature algorithm. Metadata values conveyed in the signed metadata **SHALL** take precedence over the corresponding values conveyed using plain JSON elements. + +| | | | +| :--- | :--- | :--- | +| `iss` | required | Issuer of the JWT -- unique identifying server URI. This**SHALL**match the value of a uniformResourceIdentifier entry in the Subject Alternative Name extension of the server's certificate included in the`x5c`JWT header, and**SHALL**be equal to the server's {baseURL} | +| `sub` | required | Same as`iss`. | +| `exp` | required | Expiration time integer for this JWT, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC). The`exp`time**SHALL**be no more than 1 year after the value of the`iat`claim. | +| `iat` | required | Issued time integer for this JWT, expressed in seconds since the "Epoch" | +| `jti` | required | A nonce string value that uniquely identifies this JWT. See[Section 7.1.4](general.md#jwt-claims)for additional requirements regarding reuse of values. | +| `authorization_endpoint` | conditional | A string containing the absolute URL of the server's authorization endpoint,**REQUIRED**if the`authorization_endpoint`parameter is included in the unsigned metadata | +| `token_endpoint` | required | A string containing the absolute URL of the server's token endpoint | +| `registration_endpoint` | required | A string containing the absolute URL of the server's registration endpoint | + +The client **SHALL** validate the signed metadata returned by the server as per Section 3 of [UDAP Server Metadata](https://www.udap.org/udap-server-metadata-stu1.html). If the signed metadata fails validation for any reason, the client **SHALL NOT** proceed with the workflows in this guide. + +Note: The use of the `signed_metadata` parameter in this guide is intended to align with [Section 2.1 of RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414#section-2.1). However, the requirements specified in this section are stricter than the corresponding requirements in RFC 8414. + +### Multiple Trust Communities + +A server that participates in more than one trust community may be issued different certificates from each community. However, the serialization method used to sign server metadata in the previous section of this guide requires the server to select only one certificate for use in assembling the signed JWT returned for the `signed_metadata` element. This can lead to scenarios where a client application might not trust the certificate that was selected by the server, but would have trusted one of the server’s other certificates for a different trust community. + +To address this, a client application **SHOULD** add the optional query parameter `community` to the metadata request URL described in [Section 2.1](discovery.md#discovery-of-endpoints) to indicate that it trusts certificates issued by the community identified by the parameter value. The value of the parameter **SHALL** be a URI as determined by the trust community for this purpose. + +Servers **SHALL** support the `community` parameter. IF a client includes this parameter in its metadata request and the server recognizes the community URI, the server **SHALL** select a certificate intended for use within the identified trust community, if it has been issued such a certificate, and use that certificate when generating the signed JWT returned for the `signed_metadata` element. If a server supports different UDAP capabilities for different communities, it **MAY** also return different values for other metadata elements described in [Section 2.2](discovery.md#required-udap-metadata) as appropriate for the identified community. If the server does not recognize the community URI or does not have a suitable certificate for the identified community, it **MAY** return a `204 No Content` response to the metadata request to indicate that no UDAP workflows are supported by server in the context of that community, or it **MAY** return its default metadata, i.e. the metadata that it would have returned if the `community` parameter was not included in the request. + +Note: The authors recommend that the client be prepared to handle server metadata signed with a key for a different trust community than expected, regardless if the community parameter was used. If the client cannot validate the server’s signed metadata because the server’s certificate is from a different community that is not trusted by the client, then the client will terminate the workflow as per the validation requirements in the previous section. + +--- + +# Registration - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Registration + +The requirements in this section are applicable to both consumer-facing and B2B apps and the servers that support them. + +Before FHIR data requests can be made, Client application operators **SHALL** register each of their applications with the Authorization Servers identified by the FHIR servers with which they wish to exchange data.  Client applications **SHALL** use the client_id assigned by an Authorization Server in subsequent authorization and token requests to that server. + +Authorization Servers **SHALL** support dynamic registration as specified in the [UDAP Dynamic Client Registration](https://www.udap.org/udap-dynamic-client-registration-stu1.html) profile with the additional options and constraints defined in this guide. Confidential clients that can secure a secret **MAY** use this dynamic client registration protocol as discussed further below to obtain a `client_id`. Other client types **SHOULD** follow the manual registration processes for each Authorization Server. Future versions of this guide may add support for dynamic client registration by public clients which cannot protect a private key. + +The dynamic registration workflow is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant AS as Authorization Server + C->>AS: Registration request (POST /register with signed software statement) + AS-->>C: Registration response (client_id + registration parameters) +``` + +### Software Statement + +To register dynamically, the client application first constructs a software statement as per [Section 2](https://www.udap.org/udap-dynamic-client-registration-stu1.html#section-2) of UDAP Dynamic Client Registration. + +The software statement **SHALL** contain the required header elements specified in [Section 7.1.3](general.md#jwt-headers) of this guide and the JWT claims listed in the table below.  The software statement **SHALL** be signed by the client application operator using the signature algorithm identified in the `alg` header of the software statement and with the private key that corresponds to the public key listed in the client’s X.509 certificate identified in the `x5c` header of the software statement. + +| | | | +| :--- | :--- | :--- | +| `iss` | required | Issuer of the JWT -- unique identifying client URI. This**SHALL**match the value of a uniformResourceIdentifier entry in the Subject Alternative Name extension of the client's certificate included in the`x5c`JWT header and**SHALL**uniquely identify a single client app operator and application over time. | +| `sub` | required | Same as`iss`. In typical use, the client application will not yet have a`client_id`from the Authorization Server | +| `aud` | required | The Authorization Server's "registration URL" (the same URL to which the registration request will be posted) | +| `exp` | required | Expiration time integer for this software statement, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC). The`exp`time**SHALL**be no more than 5 minutes after the value of the`iat`claim. | +| `iat` | required | Issued time integer for this software statement, expressed in seconds since the "Epoch" | +| `jti` | required | A nonce string value that uniquely identifies this software statement. See[Section 7.1.4](general.md#jwt-claims)for additional requirements regarding reuse of values. | +| `client_name` | required | A string containing the human readable name of the client application | +| `redirect_uris` | conditional | An array of one or more redirection URIs used by the client application. This claim SHALL be present if`grant_types`includes`"authorization_code"`and this claim SHALL be absent otherwise. Each URI SHALL use the https scheme. | +| `contacts` | required | An array of URI strings indicating how the data holder can contact the app operator regarding the application. The array**SHALL**contain at least one valid email address using the`mailto`scheme, e.g.`["mailto:operations@example.com"]` | +| `logo_uri` | conditional | A URL string referencing an image associated with the client application, i.e. a logo. If`grant_types`includes`"authorization_code"`, client applications**SHALL**include this field, and the Authorization Server**MAY**display this logo to the user during the authorization process. The URL**SHALL**use the https scheme and reference a PNG, JPG, or GIF image file, e.g.`"https://myapp.example.com/MyApp.png"` | +| `grant_types` | required | Array of strings, each representing a requested grant type, from the following list:`"authorization_code"`,`"refresh_token"`,`"client_credentials"`. The array**SHALL**include either`"authorization_code"`or`"client_credentials"`, but not both. The value`"refresh_token"`**SHALL NOT**be present in the array unless`"authorization_code"`is also present. | +| `response_types` | conditional | Array of strings. If`grant_types`contains`"authorization_code"`, then this element**SHALL**have a fixed value of`["code"]`, and**SHALL**be omitted otherwise | +| `token_endpoint_auth_method` | required | Fixed string value:`"private_key_jwt"` | +| `scope` | required | String containing a space delimited list of scopes requested by the client application for use in subsequent requests. The Authorization Server**MAY**consider this list when deciding the scopes that it will allow the application to subsequently request. | + +1. This guide does not currently constrain the URI scheme used to identify clients in the`iss`claim of the Authentication Token. The`https`scheme is used to identify FHIR servers, and can generally also be used for clients. However, other URI schemes can be used by communities where client app operators are not well represented by unique URLs. Communities supporting emerging concepts such as decentralized identifiers to represent client app operators may also consider using the`did`scheme for issuers of UDAP assertions. + +### Example + +#### Client Credentials + +Example software statement, prior to Base64URL encoding and signature, for a B2B app that is requesting the use of the client credentials grant type (non-normative, the “.” between the header and claims objects is a convenience notation only): + +``` +{ +  "alg": "RS256", +  "x5c": ["MIEF.....remainder omitted for brevity"] +}.{ +  "iss": "http://example.com/my-b2b-app", +  "sub": "http://example.com/my-b2b-app", +  "aud": "https://oauth.example.net/register", +  "exp": 1597186041, +  "iat": 1597186341, +  "jti": "random-value-109a3bd72" +  "client_name": "Acme B2B App", +  "contacts": ["mailto:b2b-operations@example.com"], +  "grant_types": ["client_credentials"], +  "token_endpoint_auth_method": "private_key_jwt", +  "scope": "system/Patient.read system/Procedure.read" +} + +``` + +#### Authorization Code + +Example software statement, prior to Base64URL encoding and signature, for a B2C app that is requesting the use of the authorization code grant type (non-normative, the “.” between the header and claims objects is a convenience notation only): + +``` +{ +  "alg": "RS256", +  "x5c": ["MIEF.....remainder omitted for brevity"] +}.{ +  "iss": "http://example.com/my-user-b2b-app", +  "sub": "http://example.com/my-user-b2b-app", +  "aud": "https://oauth.example.net/register", +  "exp": 1597186054, +  "iat": 1597186354, +  "jti": "random-value-f83f37a29" +  "client_name": "Acme B2B User App", +  "redirect_uris": ["https://b2b-app.example.com/redirect"], +  "contacts": ["mailto:b2b-operations@example.com"], +  "logo_uri": "https://b2b-app.example.com/B2BApp.png", +  "grant_types": ["authorization_code", "refresh_token"], +  "response_types": ["code"], +  "token_endpoint_auth_method": "private_key_jwt", +  "scope": "user/Patient.read user/Procedure.read" +} + +``` + +#### Request Body + +The registration request for use of either grant type is submitted by the client to the Authorization Server’s registration endpoint. + +``` +POST /register HTTP/1.1 +Host: oauth.example.net +Content-Type: application/json + +{ +   "software_statement": "...the signed software statement JWT...", +   "certifications": ["...a signed certification JWT..."] +   "udap": "1" +} + +``` + +The Authorization Server **SHALL** validate the registration request as per [Section 4](https://www.udap.org/udap-dynamic-client-registration-stu1.html#section-4) of UDAP Dynamic Client Registration. This includes validation of the JWT payload and signature, validation of the X.509 certificate chain, and validation of the requested application registration parameters. + +If a new registration is successful, the Authorization Server **SHALL** return a registration response with a `201 Created` HTTP response code as per [Section 5.1](https://www.udap.org/udap-dynamic-client-registration-stu1.html#section-5.1) of UDAP Dynamic Client Registration, including the unique `client_id` assigned by the Authorization Server to that client app. Since the UDAP Dynamic Client Registration profile specifies that a successful registration response is returned as per [Section 3.2.1 of RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1), the authorization server **MAY** reject or replace any of the client’s requested metadata values submitted during the registration and substitute them with suitable values. If a registration parameter has not been assigned a value by the server, the parameter **SHOULD** be omitted from the response; it **SHOULD NOT** be included with a null or empty string value. Additionally, the claims in the registration request that are defined in [Section 4.1 of RFC 7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1) (`iss`, `sub`, `aud`, `exp`, `iat`, and `jti`) are not registration parameters and **SHOULD NOT** be included in the response, as the response itself is not a JWT. + +If a new registration is not successful, e.g. it is rejected by the server for any reason, the Authorization Server **SHALL** return an error response as per [Section 5.2](https://www.udap.org/udap-dynamic-client-registration-stu1.html#section-5.2) of UDAP Dynamic Client Registration. + +### Inclusion of Certifications and Endorsements + +Authorization Servers **MAY** support the inclusion of certifications and endorsements by client application operators using the certifications framework outlined in [UDAP Certifications and Endorsements for Client Applications](https://www.udap.org/udap-certifications-and-endorsements-stu1.html). Authorization Servers **SHALL** ignore unsupported or unrecognized certifications. + +Authorization Servers **MAY** require registration requests to include one or more certifications. If an Authorization Server requires the inclusion of a certain certification, then the Authorization Server **SHALL** communicate this by including the corresponding certification URI in the `udap_certifications_required` element of its UDAP metadata. + +An example template application to declare additional information about the client application at the time of registration is described in [Section 7.4](general.md#certifications-for-client-applications) of this guilde. + +### Modifying and Cancelling Registrations + +Within a trust community, the client URI in the Subject Alternative Name of an X.509 certificate uniquely identifies a single application and its operator over time. Thus, a registered client application **MAY** request a modification of its registration with an Authorization Server by submitting another registration request to the same Authorization Server’s registration endpoint with a software statement containing a certificate corresponding to the same trust community and with the same `iss` value as was used in the original registration request. An Authorization Server accepting such a request **SHALL** only update the registration previously made in the context of the corresponding trust community, as detailed in the next paragraph, and **SHALL NOT** overwrite an existing registration made in the context of a different trust community. + +If an Authorization Server receives a valid registration request with a software statement containing a certificate corresponding to the same trust community and with the same `iss` value as an earlier software statement but with a different set of claims or claim values, or with a different (possibly empty) set of optional certifications and endorsements, the server **SHALL** treat this as a request to modify the registration parameters for the client application by replacing the information from the previous registration request with the information included in the new request. For example, an Application operator could use this mechanism to update a redirection URI or to remove or update a certification. If the registration modification request is accepted, the Authorization Server **SHOULD** return the same `client_id` in the registration response as for the previous registration. If it returns a different `client_id`, it **SHALL** cancel the registration for the previous `client_id`. + +If an Authorization Server receives a valid registration request with a software statement that contains an empty `grant_types` array from a previously registered application as per the previous paragraph, the server **SHOULD** interpret this as a request to cancel the previous registration. A client application **SHALL** interpret a registration response that contains an empty `grant_types` array as a confirmation that the registration for the `client_id` listed in the response has been cancelled by the Authorization Server. + +If the Authorization Server returns the same `client_id` in the registration response for a modification request, it SHOULD also return a `200 OK` HTTP response code. If the Authorization Server returns a new `client_id` in the registration response, the client application **SHALL** use only the new `client_id` in subsequent transactions with the Authorization Server. + +--- + +# Consumer-Facing - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Consumer-Facing + +This guide supports consumer-facing client applications using the authorization code grant type. + +Consumer-facing client applications **SHALL** obtain an access token for access to FHIR resources by following the OAuth 2.0 authorization code grant flow, with the additional options and constraints discussed below. + +### Obtaining an authorization code + +The workflow for obtaining an authorization code is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant AS as Authorization Server + C->>AS: Authorization request + Note over AS: Server incorporates user input
into authorization decision + AS-->>C: Authorization response +``` + +Client applications **SHALL** request an authorization code as per [Section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1) of RFC 6749, with the following additional constraints. Client applications and servers **MAY** optionally support UDAP Tiered OAuth for User Authentication to allow for cross-organizational or third party user authentication as described in [Section 6](user.md). + +Servers **SHALL** handle and respond to authorization code requests as per [Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2) of RFC 6749. + +Client applications and Authorization Servers **SHALL** conform to the additional constraints for authorization code flow found in [Section 7.2](general.md#authorization-code-flow) of this guide. + +### Obtaining an access token + +The workflow for obtaining an access token is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant AS as Authorization Server + C->>AS: Token request (POST /token with client_assertion JWT) + AS-->>C: Token response (access_token) +``` + +Client applications **SHALL** exchange authorization codes for access tokens as per [Section 4.1.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3) of RFC 6749, with the following additional options and constraints. + +Client applications **SHALL** include a `code_verifier` parameter and value in the token request as per Section 4.5 of RFC 7636. + +#### Constructing Authentication Token + +Client apps following this guide will have registered to authenticate using a private key rather than a shared `client_secret`. Thus, the client **SHALL** use its private key to sign an Authentication Token as described in this section, and include this JWT in the `client_assertion` parameter of its token request as described in [Section 5.1](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-5.1) of UDAP JWT-Based Client Authentication and detailed further in [Section 4.2.2](consumer.md#submitting-a-token-request) of this guide. + +Authentication Tokens submitted by client apps **SHALL** conform to the general JWT header requirements above and **SHALL** include the following parameters in the JWT claims defined in [Section 4](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-4) of UDAP JWT-Based Client Authentication: + +| | | | +| :--- | :--- | :--- | +| `iss` | required | The application's`client_id`as assigned by the Authorization Server during the registration process | +| `sub` | required | The application's`client_id`as assigned by the Authorization Server during the registration process | +| `aud` | required | The FHIR Authorization Server's token endpoint URL | +| `exp` | required | Expiration time integer for this authentication JWT, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC). | +| `iat` | required | Issued time integer for this authentication JWT, expressed in seconds since the "Epoch" | +| `jti` | required | A nonce string value that uniquely identifies this authentication JWT. See[Section 7.1.4](general.md#jwt-claims)for additional requirements regarding reuse of values. | + +The maximum lifetime for an Authentication Token **SHALL** be 5 minutes, i.e. the value of `exp` minus the value of `iat` **SHALL NOT** exceed 300 seconds. The Authorization Server **MAY** ignore any unrecognized claims in the Authentication Token. The Authentication Token **SHALL** be signed and serialized using the JSON compact serialization method. + +#### Submitting a token request + +Client applications **SHALL** submit a POST request to the Authorization Server’s token endpoint containing the following parameters as per [Section 5.1](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-5.1) of UDAP JWT-Based Client Authentication. Client apps authenticating in this manner **SHALL NOT** include an HTTP Authorization header or client secret in the token request. The token request **SHALL** include the following parameters: + +| | | | +| :--- | :--- | :--- | +| `grant_type` | required | Fixed value:`authorization_code` | +| `code` | required | The code that the app received from the Authorization Server | +| `redirect_uri` | conditional | The client application's redirection URI. This parameter**SHALL**be present only if the`redirect_uri`parameter was included in the authorization request in Section 4.1, and their values**SHALL**be identical. | +| `client_assertion_type` | required | Fixed value:`urn:ietf:params:oauth:client-assertion-type:jwt-bearer` | +| `client_assertion` | required | The signed Authentication Token JWT | +| `udap` | required | Fixed value:`1` | + +#### Server processing of token requests + +An Authorization Server receiving token requests containing Authentication Tokens as above **SHALL** validate and respond to the request as per [Sections 6 and 7](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-6) of UDAP JWT-Based Client Authentication. + +The Authorization Server **SHALL** return an error as per Section 4.6 of RFC 7636 if the client included a `code_challenge` in its authorization request but did not include the correct `code_verfier` value in the corresponding token request. + +For all successful token requests, the Authorization Server **SHALL** issue access tokens with a lifetime no longer than 60 minutes. + +This guide does not currently constrain the type or format of access tokens issued by Authorization Servers. Note that other implementation guides (e.g. SMART App Launch, IUA, etc.), when used together with this guide, may limit the allowed access token types (e.g. Bearer) and/or formats (e.g. JWT). + +### Refresh tokens + +This guide supports the use of refresh tokens, as described in [Section 1.5 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5). Authorization Servers **MAY** issue refresh tokens to consumer-facing client applications as per [Section 5 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-5). Client apps that have been issued refresh tokens **MAY** make refresh requests to the token endpoint as per [Section 6 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-6). Client apps authenticate to the Authorization Server for refresh requests by constructing and including an Authentication Token in the same manner as for initial token requests. + +--- + +# Business-to-Business - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Business-to-Business + +This guide supports B2B client applications using either the client credentials or authorization code grant types. The B2B transactions in this guide occur between a requesting organization (the Requestor operating the client application) and a responding organization (the Responder operating the OAuth Server and Resource Server holding the data of interest to the Requestor). In some cases, the Requestor’s client app operates in an automated manner. In other cases, there will also be a local user from the requesting organization (the User interacting with the Requestor’s client application). The client credentials grant type is always used for automated (aka “headless”) client apps. However, when a User is involved, either the client credentials or authorization code grant may be used, as discussed below. + +For client credentials flow, any necessary User authentication and authorization is performed by the Requestor as a prerequisite, before the Requestor’s client app interacts with the Responder’s servers, i.e. the Requestor is responsible for ensuring that only its authorized Users access the client app and only make requests allowed by the Requestor. How the Requestor performs this is out of scope for this guide but will typically rely on internal user authentication and access controls. + +Examples of automated client apps that use the client credentials grant type include SMART App Launch Backend Services and certain IUA Authorization Clients. + +For authorization code flow, the User is expected to be interacting with the Requestor’s client app in real time, at least during the initial authorization of the client app with the Responder’s OAuth Server. Typically, the User must authenticate to the Responder’s system at the time of initial authorization. If the local user has been issued credentials by the Responder to use for this purpose, the authorization code flow will typically involve use of those credentials. However, it is anticipated that in some workflows, the local user will not have their own credentials on the Responder’s system, but will instead have credentials on their “home” system. In these cases, the UDAP Tiered OAuth workflow is used so that the Responder’s OAuth Server can interact with the Requestor’s OIDC Server in an automated manner to authenticate the User, as described in [Section 6](user.md). + +Thus, this guide provides two different paths (client credentials grants and authorization code grants with Tiered OAuth) that a user affiliated with the Requestor without credentials on the Responder’s system may use to obtain access to data held by the Responder. + +B2B client applications registered to use the authorization code grant **SHALL** obtain an access token for access to FHIR resources by following the OAuth 2.0 authorization code grant flow described in [Section 4.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1) of RFC 6749, with the additional options and constraints discussed below.  + +Client applications registered to use the client credentials grant **SHALL** obtain an access token for access to FHIR resources by following the OAuth 2.0 client credentials grant flow described in [Section 4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4) of RFC 6749, and with the additional options and constraints discussed below. As noted in [Section 3](registration.md#registration), the Requestor is responsible for ensuring that the Requestor’s User, if applicable, is using the app only as authorized by the Requestor. + +### Obtaining an authorization code + +The section does not apply to client applications registered to use the client credentials grant. + +The workflow for obtaining an authorization code is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant AS as Authorization Server + C->>AS: Authorization request + Note over AS: Server incorporates user input
into authorization decision + AS-->>C: Authorization response +``` + +Client applications registered to use the authorization code grant **SHALL** request an authorization code as per [Section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1) of RFC 6749, with the following additional constraints. Client applications and servers **MAY** optionally support UDAP Tiered OAuth for User Authentication to allow for cross-organizational or third party user authentication as described in [Section 6](user.md). + +Servers **SHALL** handle and respond to authorization code requests as per [Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2) of RFC 6749. + +Client applications and Authorization Servers using the authorization code flow **SHALL** conform to the additional constraints for authorization code flow found in [Section 7.2](general.md#authorization-code-flow) of this guide. + +### Obtaining an access token + +The workflow for obtaining an access token is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant AS as Authorization Server + C->>AS: Token request (POST /token with client_assertion JWT) + AS-->>C: Token response (access_token) +``` + +Client applications using the authorization code flow **SHALL** exchange authorization codes for access tokens as per [Section 4.1.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3) of RFC 6749, with the following additional options and constraints. Client applications using the client credentials flow do not use authorization codes when requesting an access token. + +Client applications using the authorization code flow **SHALL** include a `code_verifier` parameter and value in the token request as per Section 4.5 of RFC 7636. + +#### Constructing Authentication Token + +Client apps following this guide will have registered to authenticate using a private key rather than a shared `client_secret`. Thus, the client **SHALL** use its private key to sign an Authentication Token as described in this section, and include this JWT in the `client_assertion` parameter of its token request as described in [Section 5.1](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-5.1) of UDAP JWT-Based Client Authentication and detailed further in [Section 5.2.2](b2b.md#submitting-a-token-request) of this guide. + +Authentication Tokens submitted by client apps **SHALL** conform to the general JWT header requirements in [Section 7.1](general.md#json-web-token-jwt-requirements) of this guide and **SHALL** include the following parameters in the JWT claims, as defined in [Section 4](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-4) of UDAP JWT-Based Client Authentication and [Section 4](https://www.udap.org/udap-client-authorization-grants-stu1.html#section-4) of UDAP Client Authorization Grants using JSON Web Tokens: + +| | | | +| :--- | :--- | :--- | +| `iss` | required | The application's`client_id`as assigned by the Authorization Server during the registration process | +| `sub` | required | The application's`client_id`as assigned by the Authorization Server during the registration process | +| `aud` | required | The FHIR Authorization Server's token endpoint URL | +| `exp` | required | Expiration time integer for this authentication JWT, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC) | +| `iat` | required | Issued time integer for this authentication JWT, expressed in seconds since the "Epoch" | +| `jti` | required | A nonce string value that uniquely identifies this authentication JWT. See[Section 7.1.4](general.md#jwt-claims)for additional requirements regarding reuse of values. | +| `extensions` | conditional | A JSON object containing one or more extensions. The HL7 B2B Authorization Extension Object defined below is required for B2B client apps using the`client_credentials`flow; omit for client apps using the`authorization_code`flow | + +The maximum lifetime for an Authentication Token **SHALL** be 5 minutes, i.e. the value of `exp` minus the value of `iat` **SHALL** NOT exceed 300 seconds. The Authorization Server **MAY** ignore any unrecognized claims in the Authentication Token. The Authentication Token **SHALL** be signed and serialized using the JSON compact serialization method. + +##### B2B Authorization Extension Object + +The B2B Authorization Extension Object is used by client apps following the `client_credentials` flow to provide additional information regarding the context under which the request for data would be authorized. The client app constructs a JSON object containing the following keys and values and includes this object in the `extensions` object of the Authentication JWT as the value associated with the key name `hl7-b2b`. + +| | | | +| :--- | :--- | :--- | +| `version` | required | String with fixed value:`"1"` | +| `subject_name` | conditional | String containing the human readable name of the human or non-human requestor; required if known. | +| `subject_id` | conditional | String containing a unique identifier for the requestor; required if known for human requestors when the`subject_name`parameter is present and the human requestor has been assigned an applicable identifier. Omit for non-human requestors and for human requestors who have not been assigned an applicable identifier. See Section 5.2.1.3 below for the preferred format of the identifier value string. | +| `subject_role` | conditional | String containing a code identifying the role of the requestor; required if known for human requestors when the`subject_name`parameter is present. See Section 5.2.1.3 below for the preferred format of the code value string. | +| `organization_name` | optional | String containing the human readable name of the organizational requestor. If a subject is named, the organizational requestor is the organization represented by the subject. | +| `organization_id` | required | String containing a unique identifier for the organizational requestor. If a subject is named, the organizational requestor is the organization represented by the subject. The identifier**SHALL**be a Uniform Resource Identifier (URI). Trust communities**SHALL**define the allowed URI scheme(s). If a URL is used, the issuer**SHALL**include a URL that is resolvable by the receiving party. | +| `purpose_of_use` | required | An array of one or more strings, each containing a code identifying a purpose for which the data is being requested. See Section 5.2.1.3 below for the preferred format of each code value string array element. | +| `consent_policy` | optional | An array of one or more strings, each containing a URI identifiying a privacy consent directive policy or other policy consistent with the value of the`purpose_of_use`parameter. | +| `consent_reference` | conditional | An array of one or more strings, each containing an absolute URL consistent with a[literal reference](https://www.hl7.org/fhir/R4/references.html#literal)to a FHIR[Consent](https://www.hl7.org/fhir/R4/consent.html)or[DocumentReference](https://www.hl7.org/fhir/R4/documentreference.html)resource containing or referencing a privacy consent directive relevant to a purpose identified by the`purpose_of_use`parameter and the policy or policies identified by the`consent_policy`parameter. The issuer of this Authorization Extension Object**SHALL**only include URLs that are resolvable by the receiving party. If a referenced resource does not include the raw document data inline in the resource or as a contained resource, then it**SHALL**include a URL to the attachment data that is resolvable by the receiving party. Omit if`consent_policy`is not present. | + +Servers that support the B2B client credentials flow described in this guide **SHALL** support this B2B Authorization Extension Object. Other implementation guides **MAY** define additional Authorization Extension Objects to use together with this object for B2B client credentials workflows. + +##### Preferred code systems and naming systems for US Realm + +For `subject_id`, trust communities **SHALL** constrain the allowed naming system or systems, and are encouraged to require the individual National Provider Identifier (NPI) when known for human requestors who have been assigned an individual NPI. + +For `subject_role`, trust communities **SHOULD** constrain the allowed values and formats, and are encouraged to draw from the National Uniform Claim Committee (NUCC) Provider Taxonomy Code Set, but are not required to do so to be considered conformant. + +For `purpose_of_use`, trust communities **SHOULD** constrain the allowed values, and are encouraged to draw from the HL7 [PurposeOfUse](http://terminology.hl7.org/ValueSet/v3-PurposeOfUse) value set, but are not required to do so to be considered conformant. + +##### Preferred format for identifiers and codes + +The preferred format to represent an identifier or code as a string value within an authorization extension object is as a Uniform Resource Identifier (URI) as defined in [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). Trust communities are encouraged to use this preferred format, but are not required to do so to be considered conformant with this guide. + +If the identifier or code is itself a URI, then the native representation is preferred. Otherwise, the preferred method to construct a URI is as follows: + +For identifiers, concatenate a URI identifying the namespace, the ‘#’ character, and the unique identifier assigned within the namespace. + +For codes, concatenate a URI identifying the code system, the ‘#’ character, and a code taken from the code system. + +For example, the U.S. NPI number 1234567890 can be represented as `urn:oid:2.16.840.1.113883.4.6#1234567890` and the purpose of use treatment can be represented as `urn:oid:2.16.840.1.113883.5.8#TREAT`. + +#### Submitting a token request + +##### Authorization code grant + +Client applications using the authorization code grant and authenticating with a private key and Authentication Token as per [Section 5.2.1](b2b.md#constructing-authentication-token) **SHALL** submit a POST request to the Authorization Server’s token endpoint containing the following parameters as per [Section 5.1](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-5.1) of UDAP JWT-Based Client Authentication. Client apps authenticating in this manner **SHALL NOT** include an HTTP Authorization header or client secret in its token endpoint request. The token request **SHALL** include the following parameters: + +| | | | +| :--- | :--- | :--- | +| `grant_type` | required | Fixed value:`authorization_code` | +| `code` | required | The code that the app received from the Authorization Server | +| `redirect_uri` | conditional | The client application's redirection URI. This parameter**SHALL**be present only if the`redirect_uri`parameter was included in the authorization request in Section 5.1, and their values**SHALL**be identical. | +| `client_assertion_type` | required | Fixed value:`urn:ietf:params:oauth:client-assertion-type:jwt-bearer` | +| `client_assertion` | required | The signed Authentication Token JWT | +| `udap` | required | Fixed value:`1` | + +##### Client credentials grant + +Client applications using the client credentials grant and authenticating with a private key and Authentication Token as per [Section 5.2.1](b2b.md#constructing-authentication-token) **SHALL** submit a POST request to the Authorization Server’s token endpoint containing the following parameters as per [Section 5.2](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-5.2) of UDAP JWT-Based Client Authentication. Client apps authenticating in this manner **SHALL NOT** include an HTTP Authorization header or client secret in its token endpoint request. The token request **SHALL** include the following parameters: + +| | | | +| :--- | :--- | :--- | +| `grant_type` | required | Fixed value:`client_credentials` | +| `scope` | required | Space-delimited list of requested scopes of access. | +| `client_assertion_type` | required | Fixed value:`urn:ietf:params:oauth:client-assertion-type:jwt-bearer` | +| `client_assertion` | required | The signed Authentication Token JWT | +| `udap` | required | Fixed value:`1` | + +#### Server processing of token requests + +An Authorization Server receiving token requests containing Authentication Tokens as above **SHALL** validate and respond to the request as per [Sections 6 and 7](https://www.udap.org/udap-jwt-client-auth-stu1.html#section-6) of UDAP JWT-Based Client Authentication. + +For client applications using an authorization code grant, the Authorization Server **SHALL** return an error as per Section 4.6 of RFC 7636 if the client included a `code_challenge` in its authorization request but did not include the correct `code_verfier` value in the corresponding token request. + +For all successful token requests, the Authorization Server **SHALL** issue access tokens with a lifetime no longer than 60 minutes. + +This guide does not currently constrain the type or format of access tokens issued by Authorization Servers. Note that other implementation guides (e.g. SMART App Launch, IUA, etc.), when used together with this guide, may limit the allowed access token types (e.g. Bearer) and/or formats (e.g. JWT). + +#### Client application use of access tokens + +A client application **SHALL** only use an access token in a manner consistent with any assertions made when requesting that token. For example, if a client asserted a `subject_id` and `purpose_of_use` in the B2B Authorization Extension Object included in its token request, then the access token granted in response to that request can only be used in that authorization context, i.e. for that requestor and for that purpose. If the same client application subsequently needs to retrieve a resource for a different requestor and/or for a different purpose from the same resource server, it cannot reuse the same access token. Instead, it must obtain a new access token by submitting another token request with an updated B2B Authorization Extension Object asserting the new authorization context. + +### Refresh tokens + +This guide supports the use of refresh tokens, as described in [Section 1.5 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5). Authorization Servers **MAY** issue refresh tokens to B2B client applications that use the authorization code grant type as per [Section 5 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-5). Refresh tokens are not used with the client credentials grant type. Client apps that have been issued refresh tokens **MAY** make refresh requests to the token endpoint as per [Section 6 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-6). Client apps authenticate to the Authorization Server for refresh requests by constructing and including an Authentication Token in the same manner as for initial token requests. + +--- + +# Tiered OAuth for User Authentication - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Tiered OAuth for User Authentication + +The requirements in this section are applicable to both consumer-facing and B2B apps and the servers that support them. + +Client applications registered to use the authorization code grant MAY utilize the user authentication workflow described in [UDAP Tiered OAuth for User Authentication](https://www.udap.org/udap-user-auth-stu1.html), as profiled below. The UDAP Tiered OAuth workflow allows the client application to include the base URL of a preferred OpenID Connect Identity Provider (IdP) service in the initial request to the data holder’s OAuth authorization endpoint. If Tiered OAuth is supported by the data holder and the data holder trusts the IdP indicated by the client, then the data holder will request that the IdP authenticate the user, and return authentication results and optional identity information directly to the data holder using standard OIDC workflows. Note that the client application does not interact directly with the IdP as part of this workflow. + +The Tiered OAuth workflow is summarized in the following diagram: + +```mermaid +sequenceDiagram + participant C as Client App + participant DH as Data Holder's
Authorization Server + participant IdP as Identity Provider's
Authorization Server + C->>DH: Authorization request (including idp parameter) + DH->>IdP: Discovery request + IdP-->>DH: Discovery response + DH->>IdP: Registration request (if needed) + IdP-->>DH: Registration response + DH->>IdP: Authentication request (including openid scope) + Note over IdP: IdP incorporates user input
into authentication decision + IdP-->>DH: Authentication response + DH->>IdP: Token request + IdP-->>DH: Token response (including id_token) + Note over DH: Data Holder incorporates user
input into authorization decision + DH-->>C: Authorization response +``` + +### Client Authorization Request to Data Holder + +Client apps **MAY** use this workflow with data holders that support it, as indicated in the data holder’s UDAP metadata as discussed in [Section 2](discovery.md) of this guide. A data holder that supports UDAP Tiered OAuth **SHALL** include `"udap"` in the array of scopes returned for the `scopes_supported` parameter in its UDAP metadata. When registering with a data holder that supports Tiered OAuth as per [Section 3](registration.md#registration) of this guide, client apps that intend to use the Tiered OAuth workflow **SHALL** include `udap` in the list of scopes requested in the `scope` parameter of their registration request. + +To begin the workflow, the client app indicates the preferred Identity Provider to the data holder as per Section 2 of the [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html) specification by modifying the authorization endpoint request described in [Section 4.1](consumer.md#obtaining-an-authorization-code) for consumer-facing apps or [Section 5.1](b2b.md#obtaining-an-authorization-code) for business-to-business apps as follows: + +1. Add`udap`to the list of scopes provided in the value of the`scope`query parameter, and +1. Add the extension query parameter`idp`with a value equal to the base URL of the preferred OIDC IdP. + +The meaning of the extension parameter `idp` is undefined if `udap` is absent from the list of requested scopes. The IdP’s base URL is the URL listed in the `iss` claim of ID tokens issued by the IdP as detailed in [Section 2](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) of the OpenID Connect Core 1.0 specification (OIDC Core). + +### Data Holder Authentication Request to IdP + +For the purposes of the interactions between the data holder and the IdP, the data holder takes on the role of client app and the IdP takes on the role of server/data holder and interacts as per Section 3 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html), as detailed below. + +This section describes the interactions between a data holder and an IdP where both parties support this implementation guide and where trust can be established via UDAP certificates. Note that this does not preclude data holders from validating trust with an IdP via other non-UDAP means that are outside the scope of this document, or from making authentication requests to IdPs that do not support UDAP workflows. + +Upon receiving an authorization request with a preferred IdP, the data holder first determines whether or not it trusts the IdP to perform user authentication, by retrieving and validating the IdP’s UDAP metadata from `{baseURL}/.well-known/udap`, as discussed in [Section 2](discovery.md). At a minimum, IdPs that support this guide **SHALL** include `"openid"` and `"udap"` in the array of scopes returned for the `scopes_supported` parameter. + +If the IdP is trusted and the data holder is not yet registered as a client with the IdP and the IdP supports UDAP Dynamic Registration, then the data holder **SHALL** register as a client with the IdP as per [Section 3](registration.md#registration) of this guide. The list of scopes requested in the data holder’s registration request **SHALL** include, at a minimum, `openid` and `udap`. + +If the IdP is not trusted by the data holder, or if the data holder does not have and cannot obtain a client_id to use with the IdP, the data holder **MAY** reject the client app’s authorization request by returning an error as per [Section 4.1.2.1 of RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1), using the extension error code of `invalid_idp`. Alternatively, the data holder **MAY** attempt to authenticate the user with a different trusted IdP or its own IdP, and **MAY** interact with the user to determine a suitable alternative. A client app that receives an error code of `invalid_idp` **MAY** attempt to obtain authorization again by specifying a different IdP base URL in the `idp` authorization request parameter, or by making a new authorization request without using the Tiered OAuth workflow. + +If the IdP is trusted by the data holder, and the data holder is registered as a client with the IdP, then the data holder, acting as an OIDC client, **SHALL** make an authentication request to the IdP’s authorization endpoint as per [Section 3.1.2.1 of OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) and Section 3.4 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html). The `scope` query parameter of the authentication request **SHALL** contain at least the following two values: `openid` and `udap`. The IdP **SHALL** authenticate the user as per [Sections 3.1.2.2 - 3.1.2.6 of OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequestValidation) and Sections 4.1 - 4.2 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html). + +The data holder **SHALL** validate the `state` parameter value returned in the response from the IdP. If the IdP does not return a valid `state` parameter value in its authentication response, the data holder **SHALL** return a `server_error` error response to the client app and terminate this workflow as per Section 4.1 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html). If the IdP returns an error response with a valid `state` parameter value, the data holder **SHALL** return a suitable error response to the client app and terminated this workflow as per Section 4.2 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html). + +If the IdP returns a successful authentication response with valid `state` parameter value and an authorization code, the data holder **SHALL** exchange the code for an access token and ID Token by making a request to the IdP’s token endpoint as per [Section 3.1.3.1 of OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest) and Section 4.3 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html). For this request, the data holder as client app **SHALL** utilize the JWT-based authentication process as described in [Section 4.2.2](consumer.md#submitting-a-token-request) of this guide. ID Tokens issued by the IdP **SHALL** conform to the requirements of [Section 7.1](general.md#json-web-token-jwt-requirements) of this guide and Section 4.3 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html). + +If the IdP returns an ID Token, the data holder **SHALL** then validate the ID Token as per [Section 3.1.3.5 of OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponseValidation). If the IdP does not return an ID Token, or the ID Token cannot be successfully validated, or an error response is retured by the IdP, the data holder **MAY** return an `invalid_idp` error code to the client app or attempt an alternate user authentication as described above. + +### Data holder interaction with user after authentication + +When an ID Token has been returned and validated, the data holder **SHOULD** use the ID Token to attempt to match the authenticated user to a user or role in its own system, as appropriate for the resources requested. As discussed in Sections 4.4 and 4.5 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html), the `iss` and `sub` values of the ID Token can be used together by the data holder to identify a single person over time, i.e. the data holder can attempt to map the pair (`iss`,`sub`) to a known users in the data holder’s system. If the data holder has previously performed this mapping or has otherwise bound the pair (`iss`,`sub`) to a local user or role, it **MAY** rely on this previous mapping for subsequent authentications. If the ID Token does not contain sufficient information to perform the mapping, the data holder **MAY** attempt to retrieve additional information for the IdP’s UserInfo endpoint as described in [Section 5.3 of OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). In many cases, the information provided by the IdP will allow the data holder to resolve the authenticated user to a single local user or role with high confidence. If necessary, the data holder **MAY** interact with the user following the redirection from the IdP back to the data holder’s redirection URI to increase confidence in the resolution process. For example, if there is more than one possible match, the data holder may transmit a one-time code to an electronic address of record known to the data holder to confirm a specific match. If the data holder is unable to resolve the authenticated user to a local user or role, as appropriate for the resources requested, it **SHALL** return an `access_denied` error response to the client app’s authorization request and terminate the workflow. + +If the data holder successfully maps the authenticated user to a user or role in its own system, as appropriate for the resources requested, it **SHALL** also obtain authorization from the user for the scopes requested by the client app, if such authorization is required, as per Section 4.5 of [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html), returning to the workflow defined in [Section 4.1](consumer.md#obtaining-an-authorization-code) or [Section 5.1](b2b.md#obtaining-an-authorization-code) of this guide, for consumer-facing or B2B apps, respectively. + +### Examples + +Note: These examples are non-normative. Line breaks and indentations have been added for readability and would not be part of an actual request or response. Additional examples can be found in the [UDAP Tiered OAuth](https://www.udap.org/udap-user-auth-stu1.html) specification. + +#### Example client app authorization request + +``` +GET /authorize? + response_type=code& + state=client_random_state& + client_id=myIdIssuedByResourceHolder& + scope=udap+user/*.read& + idp=https://preferred-idp.example.com/optionalPathComponent& + redirect_uri=https://client.example.net/redirect HTTP/1.1 +Host: resourceholder.example.com + +``` + +#### Example data holder error response + +``` +HTTP/1.1 302 Found +Location: https://client.example.net/clientredirect? + error=invalid_idp& + error_description=The+requested+identity+provider+cannot+be+used+to+sign+in+to+this+system + state=client_random_state + +``` + +#### Example data holder authentication request to IdP + +``` +HTTP/1.1 302 Found +Location: https://idp.example.com/optionalpath/authorize? + response_type=code& + state=resource_holder_random_state& + client_id=resourceHolderClientIDforIdP& + scope=openid+udap& + nonce=resource_holder_nonce& + redirect_uri=https://resourceholder.example.net/redirect + +``` + +#### Example data holder token request to IdP + +``` +POST /optionalpath/token HTTP/1.1 +Host: idp.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=authorization_code& + code=authz_code_from_idp& + client_assertion_type=urn:ietf:params:oauth:grant-type:jwt-bearer& + client_assertion=eyJh[…remainder of AnT omitted for brevity…]& + udap=1 + +``` + +--- + +# General Requirements - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## General Requirements + +This section contains general requirements applicable to multiple authorization and authentication workflows. + +### JSON Web Token (JWT) Requirements + +Both the producers and consumers of JWTs specified in this guide **SHALL** conform to the requirements of [RFC 7515](https://datatracker.ietf.org/doc/html/rfc7515) and the additional requirements below. + +#### General requirements and serialization + +All JSON Web Tokens (JWTs) defined in this guide: + +1. **SHALL**conform to the mandatory requirements of[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519). +1. **SHALL**be JSON Web Signatures as defined in[RFC 7515](https://datatracker.ietf.org/doc/html/rfc7515). +1. **SHALL**be serialized using JWS Compact Serialization as per[Section 7.1](https://datatracker.ietf.org/doc/html/rfc7515#section-7.1)of RFC 7515. + +#### Signature algorithm identifiers + +Signature algorithm identifiers used in this guide are defined in [Section 3.1](https://datatracker.ietf.org/doc/html/rfc7518#section-3.1) of RFC 7518. + +| | | +| :--- | :--- | +| `RS256` | Implementers**SHALL**support this algorithm. | +| `ES256` | Implementers**SHOULD**support this algorithm. | +| `RS384` | Implementers**MAY**support this algorithm. | +| `ES384` | Implementers**MAY**support this algorithm. | + +While this guide mandates a baseline of support, clients and servers **MAY** support and use additional signature algorithms that meet the security requirements of the use case. + +#### JWT headers + +All JWTs defined in this guide **SHALL** contain a Javascript Object Signing and Encryption (JOSE) header as defined in [Section 4](https://datatracker.ietf.org/doc/html/rfc7515#section-4) of RFC 7515 that conforms to the following requirements: + +| | | | +| :--- | :--- | :--- | +| `alg` | required | A string identifying the signature algorithm used to sign the JWT. For example:`"RS256"` | +| `x5c` | required | An array of one or more strings containing the X.509 certificate or certificate chain, where the leaf certificate corresponds to the key used to digitally sign the JWT. Each string in the array is the base64-encoded DER representation of the corresponding certificate, with the leaf certificate appearing as the first (or only) element of the array.See[Section 4.1.6 of RFC 7515](https://tools.ietf.org/html/rfc7515#section-4.1.6). | + +#### JWT Claims + +All JWTs defined in this guide contain the `iss`, `exp`, and `jti` claims. The value of the `jti` claim is a nonce string value that uniquely identifies a JWT until the expiration of that JWT, i.e. until the time specified in the `exp` claim of that JWT has passed. Thus, the issuer of a JWT **SHALL NOT** reuse the same `jti` value in a new JWT with the same `iss` value prior to the expiration of the previous JWT. Implementers who track `jti` values to detect the replay of received JWTs **SHALL** allow a `jti` value to be reused after the expiration of any other previously received JWTs containing the same `iss` and `jti` values. + +Additional JWT Claim requirements are defined elsewhere in this guide. + +#### Normalization of URI values in JWTs + +This guide does not require the canonicalization of URIs included in JWTs before making string comparisons. Per Section 2 of [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), URI values in JWTs are compared as case-sensitive strings with no transformations or canonicalizations applied. The JWT producer **SHOULD** include URI values that are normalized to facilitate successful string matching by the JWT consumer. The JWT consumer is **NOT REQUIRED** to perform any normalization of URI values prior to making comparisons. For example, a JWT producer cannot assume that a JWT consumer will consider `"https://FHIR.example.com:443"` and `"https://fhir.example.com/"` to be equivalent strings when evaluating a JWT. + +Note: To ensure interoperability, a URI included as the `iss` value of a JWT should exactly match a URI included in the Subject Alternative Name extension of the JWT producer’s corresponding certificate, including, for example, the case used for the host name and the presence or absence of a port number or trailing slash. Similarly, clients that include a URI provided by a server in its server metadata as the `aud` value of a JWT subsequently submitted to that server should include the URI exactly as it was originally provided by that server. + +#### JWT validation + +Several workflows described in this guide require validation of JWTs by the JWT consumer. This includes the requirement that the JWT consumer validate that it trusts the corresponding JWT’s producer’s X.509 certificate by constructing a valid certificate chain from the JWT producer’s certificate to an anchor trusted by the JWT consumer, and by verifying that the certificates in the chain have not expired or been revoked. The full validation requirements for each workflow can be found in the UDAP profile sections referenced in Sections [2.3](discovery.md#required-udap-metadata), [3.2.3](registration.md#request-body), [4.2.3](consumer.md#server-processing-of-token-requests), and [5.2.3](b2b.md#server-processing-of-token-requests). + +### Authorization code flow + +The constraints in the following subsections apply to all workflows utilizing the authorization code flow. Authorization requests submitted by client applications **SHALL** include the following parameters: + +| | | | +| :--- | :--- | :--- | +| `response_type` | required | Fixed value:`code` | +| `client_id` | required | The client identifier issued to the client application at registration. | +| `redirect_uri` | conditional | The client application's redirection URI for this session,**REQUIRED**when the client application registered more than one redirection URI. The value**SHALL**match one of the redirection URIs registered by the client. | +| `scope` | required | Space-delimited list of requested scopes of access. | +| `state` | required | An opaque value used by the client to maintain state between the request and callback, as discused further in[Section 7.2.1](#the-state-parameter) | +| `code_challenge` | required | PKCE code challenge, as discussed further in[Section 7.2.2](#proof-key-for-code-exchange-pkce) | +| `code_challenge_method` | required | Fixed value:`S256` | + +#### The state parameter + +A Client application **SHALL** include the `state` parameter in its authorization request. An Authorization Server **SHALL** return an error code of `invalid_request` as per Section 4.1.2.1 of RFC 6749 if a client application does not include a `state` value in its authorization request. + +Servers **SHALL** include the `state` parameter and corresponding value provided by the client application in the authorization response as per RFC 6749. The client application **SHALL NOT** proceed if the `state` parameter is not included in the authorization response or its value does not match the value provided by the client application in the corresponding authorization request. + +#### Proof Key for Code Exchange (PKCE) + +Client applications and Authorization Servers **SHALL** utilize Proof Key for Code Exchange (PKCE) with `code_challenge_method` of `S256` as defined in RFC 7636. An Authorization Server **SHOULD** return an error as per Section 4.4.1 of RFC 7636 if a client application does not include a `code_challenge` is its authorization request. + +The Authorization Server **SHALL** return an error in response to a token request as per Section 4.6 of RFC 7636 if the client included a `code_challenge` in its authorization request but did not include the correct `code_verfier` value in the corresponding token request. + +### Scope negotiation + +A wildcard scope is a scope that can be alternatively represented as a set of non-wildcard scopes. An example of a wildcard scope is the SMART App Launch v1.0.0 scope `patient/Observation.*` which can expanded to the set of two non-wildcard scopes: `patient/Observation.read` and `patient/Observation.write`. Granting the wildcard scope to a client application is equivalent to granting the corresponding expanded set of non-wildcard scopes. + +The constraints enumerated below apply for scope negotiation between client applications and servers. Unless otherwise specified, these constraints apply for both registration requests and access token requests made by client applications, and the corresponding responses returned by servers. + +1. Client applications and servers**MAY**support wildcard scopes. +1. The`scopes_supported`metadata**SHALL**be present in the .well-known/udap object and**SHALL**list all scopes supported by the server including all supported wildcard scopes. +1. A client application**MAY**request a wildcard scope only if wildcards are specified in the server’s`scopes_supported`metadata list. +1. If a client application requests a wildcard scope and the server supports wildcards, then the server**SHOULD**return either the wildcard scope or an expanded set of scopes that the client has been granted in its response. +1. If a client application requests a wildcard scope and the server does not support wildcard scopes, then the server**SHOULD**respond with an error of “invalid_client_metadata” for registration requests or an error of “invalid_scope” for token requests. +1. If a server supports scopes defined in other specifications or implementaton guides for a workflow defined in this guide, then the server**SHOULD**include the corresponding scopes in its`scopes_supported`metadata. +1. In registration requests and token requests, an authorization server**MAY**grant all scopes requested by the client or a subset thereof. It**MAY**also grant additional scopes that were not in the set of scopes requested by the client application. For example, the set of scopes granted or not granted by the server could be based on technical or policy guidelines at the responding organization, or, for token requests, based on the application having registered with the server for a different set of scopes. +1. A server**SHALL**include the`scope`parameter in a token response if the set of scopes granted by the server to the client application is not identical to the set of scopes requested by the client application, or if the client application does not include a set of requested scopes in its request. +1. A server**SHOULD**respond with an error of “invalid_scope” for token requests only if a wildcard scope is requested and not supported, or if none of the requested scopes are supported. +1. A server**SHOULD**respond with an error of “invalid_client_metadata” for registration requests if a wildcard scope is requested and not supported, if none of the requested scopes are supported, and/or if the server will not grant any of the requested scopes to the client based on technical or policy guidelines at the responding organization. +1. Client applications**SHOULD**be able to handle a response granting a different set of scopes than the scopes requested. This may be a superset, subset, or entirly different set, as described in items 4 and 6 above. + +### Certifications for client applications + +As discussed in [UDAP Certifications and Endorsements for Client Applications](https://www.udap.org/udap-certifications-and-endorsements-stu1.html), certifications can be used by client applications or third parties to declare additional information about a client application at the time of registration. + +The table in Section 7.4.1 provides a template for UDAP Certification definitions. A trust community **MAY** publish one or more Certification definitions using this template. A Certification definition specifies the values to be used for the `certification_name` and `certification_uris` keys and the allowed `grant_types`. The trust community determines whether or not the optional `scopes` and `extensions` keys will be included in their Certification definition, any restrictions on their allowed values, and whether these keys will be optional, required, or conditionally included when generating a certification. If the `extensions` keys are used, the Certification definition specifies the additional extensions keys to be included in the `extensions` object, as discussed in section 7.4.2. + +The trust community also determines who will sign the certification, e.g. the app operator or another party. For example, a certification self-signed by a client app operator can be used to declare the intended use of the application within a trust community, while certifications signed by another party, such as the trust community administrator or an independent accreditor, can be used to assist servers in determining what a client application is authorized to do within a trust community. Note that a trust community could use such a certification to communicate the exchange purposes for which a particular client application operator has been approved. + +Using a Certification definition provided by the trust community, a client application or third party **MAY** generate a certification by constructing a signed JWT conforming to requirements of the certification definition and this section. The certification **SHALL** contain the required header elements specified in [Section 7.1.3](general.md#jwt-headers) of this guide and the JWT claims listed in the certification definition. The certification **SHALL** be signed by the client application operator or by a third party, as specified in the certification definition, using the signature algorithm identified in the `alg` header of the certification and with the private key that corresponds to the public key listed in the signer’s X.509 certificate identified in the `x5c` header of the certification. + +Recognized Certification JWT claims and server processing rules for Certifications submitted by a client application are detailed in [UDAP Certifications and Endorsements for Client Applications](https://www.udap.org/udap-certifications-and-endorsements-stu1.html). + +#### Certification template + +| | | | +| :--- | :--- | :--- | +| `iss` | required | Issuer of the JWT -- unique identifying URI of the signing entity. This**SHALL**match the value of a uniformResourceIdentifier entry in the Subject Alternative Name extension of the signer's certificate included in the`x5c`JWT header and**SHALL**uniquely identify a single signing entity over time. | +| `sub` | required | Subject of the JWT -- unique identifying client URI. This**SHALL**match the value of a uniformResourceIdentifier entry in the Subject Alternative Name extension of the client's certificate and**SHALL**uniquely identify a single client app operator and applications over time. For a self-signed certification, this is same as`iss`. | +| `aud` | optional | The "registration URL" of the intended Authorization server(s), i.e. the same URL to which the registration request will be posted. If absent, this certification is intended for all Authorization Servers. The value can be a single string or array of strings. | +| `exp` | required | Expiration time integer for this software statement, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC). The`exp`time**SHALL**be no more than 3 years after the value of the`iat`claim. | +| `iat` | required | Issued time integer for this software statement, expressed in seconds since the "Epoch" | +| `jti` | required | A nonce string value that uniquely identifies this software statement. See[Section 7.1.4](general.md#jwt-claims)for additional requirements regarding reuse of values. | +| `certification_name` | required | string with fixed value defined by the trust community, e.g. "Example HL7 Client App Certification" | +| `certification_uris` | required | array of one or more string with fixed values defined by the trust community, e.g.["http://community.example.com/certifications/example-certifications"]. | +| `grant_types` | required | Array of strings, each representing a requested grant type, from the following list:`"authorization_code"`,`"refresh_token"`,`"client_credentials"`. The array**SHALL**include either`"authorization_code"`or`"client_credentials"`, but not both. The value`"refresh_token"`**SHALL NOT**be present in the array unless`"authorization_code"`is also present. | +| `response_types` | conditional | Array of strings. If`grant_types`contains`"authorization_code"`, then this element**SHALL**have a fixed value of`["code"]`, and**SHALL**be omitted otherwise | +| `scope` | optional | String containing a space delimited list of scopes that may be requested by the client application in subsequent requests. The Authorization Server**MAY**consider this list when deciding the scopes that it will allow the application to subsequently request. Note for client apps that also support the SMART App Launch framework: certifications for apps requesting the`"client_credentials"`grant type**SHOULD**lisst system scopes; certifications for apps requesting the`"authorization_code"`grant type**SHOULD**list user or patient scopes. | +| `extensions` | optional | A JSON object containing one or more certification extension keys, as discussed in the following section. | + +#### Certification extension keys example + +When defining a Certification, a trust community **MAY** define one or more extension keys to be included in the `extensions` object of the Certification JWT, the JSON type of the corresponding value, and the conditions under which the key is present, including whether the use of the key is optional, required, etc. The value of each extension key **SHALL** be a JSON value or a JSON object. For example, a Certification definition could specify that the value of a key is a number, an array of strings, or a FHIR [Questionnaire](https://www.hl7.org/fhir/R4/questionnaire.html) resource, as appropriate for its intended use. + +Two non-normative examples of extension keys that could be considered for inclusion in a Certification are presented in the table below: + +| | | +| :--- | :--- | +| `example_exchange_purposes` | Array of strings, each containing a URI identifying an exchange purpose recognized by the trust community. | +| `example_privacy_disclosures` | A JSON object containing a set of privacy-related keys and acceptable values established by the trust community.For example:1. the key`funding`could be used to express the app's source of funding.2. the key`data_storage`could be used to identify where a patient's data is stored.3. the key`data_access_notification`could be used to indicate whether a user is notified when their data is accessed by someone else.Note: This example extension key is derived from an example Certification previously published by Carequality, which can be viewed[here](https://carequality.org/wp-content/uploads/2020/12/Carequality-Consumer-Facing-App-Certification-Profile.pdf). | + +### Using this guide with the SMART App Launch framework + +This guide is intended to be compatible and harmonious with client and server use of versions 1 or 2 of the HL7 SMART App Launch IG. Although the use of the SMART App Launch framework is not required to be conformant with this guide, this section provides guidance on how the UDAP and SMART App Launch frameworks can be used together successfully. + +The FAST Security project team is working to identify any potential incompatibilities experienced by servers or client applications that support both this IG and the SMART App Launch IG concurrently. Implementers are requested to submit feedback regarding any other potential issues they have identified related to the concurrent use of both IGs so these may be addressed and resolved in future updates. + +#### Consistent use of both guides + +The question has been raised as to whether this IG can be used for client registration but not used for subsequent authentication. Though adopters of this IG sometimes colloquially refer to its entire workflow as “Dynamic Client Registration”, authentication consistent with this IG is also core to a compliant implementation and the HL7 UDAP FAST Security workgroup recommends that trust communities adopting this IG require the use of this IG for both client registration and authentication, even when SMART is also used, since omitting the UDAP workflow from the authentication step significantly reduces the security benefits to the community. + +#### Key Algorithms + +JWT-based authentication in version 2 of the SMART IG requires server support for either the RS384 or ES384 signature algorithms, while this IG requires server support for RS256. However, this does not present a compatibility issue because RS256 is permitted as an optional algorithm in the SMART IG, and both RS384 and ES384 are permitted as optional algorithms in this IG. Therefore, using any of these three signature algorithms would be compliant with both IGs. It is also noted that for 2048-bit and 3072-bit RSA keys, for example, RS384 does not provide additional security strength beyond RS256. + +#### Public Key Distribution + +This guide uses X.509 certificates included inline within JWTs to distribute public keys. The entity generating a JWT includes the corresponding certificate in the `x5c` header of every signed JWT. Therefore, no separate key discovery or retrieval mechanism is required by the party consuming the JWT. The SMART App Launch framework instead prefers that client apps publish their public keys at a publicly available URL using the JWKS format, and submit this JWKS URL during registration. A server will then dereference this URL to obtain the client’s public key. However, this does not present a compatibility issue because the inclusion of the `x5c` header is not prohibited by the SMART IG, and a client can also publish the public keys from its certificate(s) in JWKS format at a JWKS URL or provide the JWKS directly to the server if necessary for strict conformance with the SMART IG. We note that, in practice, the public key in the certificate provided by the client would allow a server to validate the JWT signature without the need to dereference any JWKS URL provided. We also note that the SMART IG makes no stipulation regarding a server registering a key with a client, e.g. for validation of signed server metadata. + +#### Discovery + +Both this guide and the SMART App Launch framework have requirements regarding the publishing of server metadata at well-known URLs. There is some expected overlap in the UDAP metadata elements defined in [Section 2](discovery.md) of this guide and the metadata required by the SMART IG, e.g. OAuth 2.0 authorization and token endpoints are included in both. This is not a compatibility issue as each guide specifies a different metadata endpoint. Having different metadata endpoints permits servers to return different metadata values for different workflows. For example, a server could operate a different token endpoint to handle token requests from clients conforming to this guide, or use the same endpoint for token requests conforming to both guides. We also note a server can easily identify token requests conforming to this guide by the presence of the extension parameter `udap` with a value of `1` in the request body. + +#### Scopes + +This guide does not restrict the scopes defined outside this guide that may be supported by a client or server. It is expected that clients that support both this guide and the FHIR resource scopes defined in the SMART App Launch framework will request SMART system-level scopes when using the `"client_credentials"` grant type and either SMART user-level or patient-specific scopes when using the `"authorization_code"` grant type. + +#### Authorization Requests + +Client applications that support both this guide and the SMART App Launch IG are not required to include a launch scope or launch context requirement scope in an authorization request. However, the capability for a client application to request a launch context from the server is useful in many workflows, e.g. consumer facing workflows. Since this IG does not restrict the inclusion of additional parameters in an authorization request or in the corresponding server response, clients are able initiate either the SMART standalone or EHR launch workflows to request a launch context. For example, a client could initiate the SMART standalone launch by including the `launch/patient` scope in its authorization request to a server that supports this SMART workflow. + +#### Token Requests + +For clients and servers that also support version 1 of the SMART App Launch IG, the requirement to authenticate using a private key in Section 4.2.1 overrides the requirement for the client to use HTTP Basic Authentication with a client_secret in [Section 7.1.3](http://hl7.org/fhir/smart-app-launch/1.0.0/index.html#step-3-app-exchanges-authorization-code-for-access-token) of the SMART App Launch IG v1.0.0. It is noted that this override is only applicable to version 1, as subsequent versions of the SMART IG no longer requires client to use Basic Authentication. + +#### Token Responses + +Although this guide does not currently constrain the type or format of access tokens, the SMART App Launch framework, when used together with this guide, may limit the allowed access token types (e.g. Bearer) and/or formats (e.g. JWT). Since this IG does not restrict the server from including additional parameters in the token response, servers can include other parameters specified by the SMART App Launch framework for this purpose, e.g. launch context parameters. + +### Experimental workflow alternative using ‘jku’ dereferencing + +Since many servers support `jku` dereferencing for certain SMART App Launch workflows, the question has been raised as to whether there may be some advantage to allowing clients and servers to re-use this `jku` mechanism for UDAP workflows, as an alternative to requiring a JWT signer to include their certificate inline in the `x5c` header of the JWT. To facilitate future discussion of this topic, this guide defines the following experimental workflow changes for testing purposes. Implementer feedback is requested to determine whether to expand or remove this option in future versions of this guide. + +This sections defines an experimental JWT processing alernative to test the use of `jku` dereferencing for access token request/response workflows. Support for this variation is **OPTIONAL** for both clients and servers, and may be removed in future versions of this guide. This variation overrides the requirement in [Section 7.1.3](general.md#jwt-headers) to include an `x5c` header in a JWT. This section does not apply to registration requests or to JWTs signed by servers. + +Alternative workflow: + +1. Clients**MAY**omit the`x5c`header from an Authentication JWT and instead include the`jku`header containing their pre-registered JWKS URL and the`kid`header identifying a key in the corresponding JWKS key set. If the`jku`header is included, then the key entry from the JWKS set at this URL matching the`kid`value in the JWT header**SHALL**include an`x5c`parameter populated with the corresponding certificate data in the same manner that the`x5c`JWT header would have been populated if it had been included in the JWT. +1. Servers that receive a JWT in a UDAP worfklow without an`x5c`header**MAY**dereference the`jku`header, attempt to locate the`x5c`parameter from the key entry corresponding to the`kid`value in the JWT, and use the`x5c`value from the JWKS in subsequent processing in the same way as if it had been included directly in the JWT as the value of`x5c`JWT header. +1. Clients intending to utilize this workflow**SHALL**register their JWKS URL by including the`jku`parameter with the JWKS URL value in their signed software statement at the time of registration. + +--- + +# Artifacts Summary - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Artifacts Summary + +This page provides a list of the FHIR artifacts defined as part of this implementation guide. + +--- + +# About - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## About + +### Package File + +The following package file includes an NPM package file used by many FHIR tools. It includes all the value sets, profiles, extensions, list of pages and urls in the IG, etc. for this version of the Implementation Guide. This file should be the first choice when generating any implementation artifacts as it contains all of the rules about what makes the profiles valid. Implementers will still need to be familiar with the contents of this specification and the applicable profiles in order to make a conformant implementation. See the overview on validating [FHIR profiles and resources](http://hl7.org/fhir/R4/validation.html): + +[Package](package.tgz) + +### Downloadable Copy of Entire Specification + +The following ZIP file contains a downloadable version of this IG that can be hosted locally: [Downloadable Copy](full-ig.zip) + +### Cross Version Analysis + +This is an R4 IG. None of the features it uses are changed in R4B, so it can be used as is with R4B systems. Packages for both [R4 (hl7.fhir.us.udap-security.r4)](package.r4.tgz) and [R4B (hl7.fhir.us.udap-security.r4b)](package.r4b.tgz) are available. + +### Dependency Table + +### Globals Table + +*There are no Global profiles defined* + +### Notices + +HL7®, FHIR®, the HL7® logo, and the FHIR® flame design are registered trademarks of Health Level Seven International. UDAP™ and the gear design are trademarks of UDAP.org. No use of external IP + +### Credits + +Editor: Luis C. Maas, EMR Direct and UDAP.org + +This implementation guide was made possible by the thoughtful contributions and feedback of the following people and organizations: + +The members of the ONC FHIR at Scale Taskforce (FAST) Security Tiger Team + The members of the HL7/UDAP.org joint project working group + The members of the HL7 Security Work Group + +--- + +# Change Log - Security for Scalable Registration, Authentication, and Authorization v2.0.0 + + +## Change Log + +Changes from the previous version are summarized below with links to the corresponding HL7 ticket. The summaries below are non-normative. + +### Version 2.0.0 + +| | | +| :--- | :--- | +| [FHIR-40510](https://jira.hl7.org/browse/FHIR-40510) | Update client and server requirements for “community” parameter | +| [FHIR-41520](https://jira.hl7.org/browse/FHIR-41520) | Clarify “state” parameter required for authorization code flow | +| [FHIR-42958](https://jira.hl7.org/browse/FHIR-42958) | Add guidance for use of PKCE | +| [FHIR-43003](https://jira.hl7.org/browse/FHIR-43003) | Update server metadata requirements for extensions and certifications | +| [FHIR-43005](https://jira.hl7.org/browse/FHIR-43005) | Clarify server may grant a subset of “scopes_supported” | +| [FHIR-43006](https://jira.hl7.org/browse/FHIR-43006) | Clarify scope requirements for Tiered OAuth | +| [FHIR-43020](https://jira.hl7.org/browse/FHIR-43020) | Clarify where client requests scopes in each workflow | +| [FHIR-43022](https://jira.hl7.org/browse/FHIR-43022) | Clarify use of a client secret is not permitted | +| [FHIR-43024](https://jira.hl7.org/browse/FHIR-43024) | Add STU Note regarding concurrent use with SMART | +| [FHIR-43120](https://jira.hl7.org/browse/FHIR-43120) | Clarify JWT conformance requirements | +| [FHIR-45173](https://jira.hl7.org/browse/FHIR-45173) | Add certification example for privacy disclosures | +| [FHIR-45723](https://jira.hl7.org/browse/FHIR-45723) | Clarify scopes to register for when using Tiered OAuth | +| [FHIR-46113](https://jira.hl7.org/browse/FHIR-46113) | Add certification example for exchange purposes | +| [FHIR-46448](https://jira.hl7.org/browse/FHIR-46448) | Add scope guidance based on TEFCA SOP | +| [FHIR-48921](https://jira.hl7.org/browse/FHIR-48921) | Clarify that Registered Claim Names should be omitted from registration response | +| [FHIR-48986](https://jira.hl7.org/browse/FHIR-48986) | Add text and STU note about logical server groups | +| [FHIR-49141](https://jira.hl7.org/browse/FHIR-49141) | Move conformance language out of home page | +| [FHIR-49142](https://jira.hl7.org/browse/FHIR-49142) | Update invalid conformance language | +| [FHIR-49143](https://jira.hl7.org/browse/FHIR-49143) | Representation/formatting of word may be confused as conformance language | +| [FHIR-49174](https://jira.hl7.org/browse/FHIR-49174) | Clarify token use must be consistent with authorization context | +| [FHIR-49175](https://jira.hl7.org/browse/FHIR-49175) | Expand introduction to explain trust communities and certificate-based trust | +| [FHIR-49176](https://jira.hl7.org/browse/FHIR-49176) | Update UDAP.org STU1 links | +| [FHIR-49177](https://jira.hl7.org/browse/FHIR-49177) | Require supported signing algorithms for registration in server metadata | +| [FHIR-49178](https://jira.hl7.org/browse/FHIR-49178) | Move US Realm requirements from 5.2.1.1; Change subject_id to recommend NPI | +| [FHIR-49179](https://jira.hl7.org/browse/FHIR-49179) | Remove reference to SMART configuration for scope negotiation | +| [FHIR-49180](https://jira.hl7.org/browse/FHIR-49180) | Consolidate scope negotiation requirements, add trust community scope guidance | +| [FHIR-49181](https://jira.hl7.org/browse/FHIR-49181) | Clarify how certification template is used and the non-normative nature of examples | +| [FHIR-49182](https://jira.hl7.org/browse/FHIR-49182) | Clarify that additional signature algorithms may be used | +| [FHIR-49184](https://jira.hl7.org/browse/FHIR-49184) | Add underlying UDAP.org reference for “extensions” parameter | +| [FHIR-49185](https://jira.hl7.org/browse/FHIR-49185) | Add guidance on how to use this IG and SMART App Launch framework together | +| [FHIR-49186](https://jira.hl7.org/browse/FHIR-49186) | Add section level references to validation steps in UDAP specifications | +| [FHIR-49187](https://jira.hl7.org/browse/FHIR-49187) | Move JWT requirements to General Requirements page | +| [FHIR-49239](https://jira.hl7.org/browse/FHIR-49239) | Clarify signed metadata has precedence over plain JSON elements | +| [FHIR-49241](https://jira.hl7.org/browse/FHIR-49241) | Add alternative workflow using “jku” instead of “x5c” (experimental) | +| [FHIR-49242](https://jira.hl7.org/browse/FHIR-49242) | Clarify that clients will terminate workflow if server metadata is not trusted | +| [FHIR-49631](https://jira.hl7.org/browse/FHIR-49631) | Clarify that unassigned parameters should be omitted from registration response | +| [FHIR-49632](https://jira.hl7.org/browse/FHIR-49632) | Clarify addition and removal of scopes by server during scope negotiation | +| [FHIR-49633](https://jira.hl7.org/browse/FHIR-49633) | Example narrative should be for B2C and authorization code in 3.2.2 | +| [FHIR-50137](https://jira.hl7.org/browse/FHIR-50137) | Clarify scope negotiation errors for token vs. registration requests | +| [FHIR-50929](https://jira.hl7.org/browse/FHIR-50929) | Remove dependency of hl7.fhir.us.core: 3.1.1 | +| [FHIR-50963](https://jira.hl7.org/browse/FHIR-50963) | Specify the IG standards status | +| [FHIR-51052](https://jira.hl7.org/browse/FHIR-51052) | Clarify URI normalization expectations in JWTs | +| [FHIR-51054](https://jira.hl7.org/browse/FHIR-51054) | Add time synchronization to community checklist | +| [FHIR-51150](https://jira.hl7.org/browse/FHIR-51150) | Add PKI policies as separate trust community checklist item | +| [FHIR-51161](https://jira.hl7.org/browse/FHIR-51161) | Clarify additional Authorization Extension Objects are allowed for B2B workflows | +| [FHIR-51244](https://jira.hl7.org/browse/FHIR-51244) | Pluralize exchange_purpose in example certification keys table | + +### Version 1.1.0 - STU1 Update 1 + +| | | +| :--- | :--- | +| [FHIR-40459](https://jira.hl7.org/browse/FHIR-40459) | Clarify client is required to validate signed_metadata as per the UDAP server metadata profile | +| [FHIR-40579](https://jira.hl7.org/browse/FHIR-40579) | Correct inactive link in Required UDAP Metadata | +| [FHIR-40601](https://jira.hl7.org/browse/FHIR-40601) | Correct invalid link to HL7 SMART App Launch IG history | +| [FHIR-40791](https://jira.hl7.org/browse/FHIR-40791) | Clarify “aud” value in authentication JWTs | +| [FHIR-41517](https://jira.hl7.org/browse/FHIR-41517) | Clarify algorithm used by servers to sign UDAP metadata | +| [FHIR-43002](https://jira.hl7.org/browse/FHIR-43002) | Clarify that support for B2B extension is required for servers that support client credentials grants | +| [FHIR-43007](https://jira.hl7.org/browse/FHIR-43007) | Clarify conformance strength of algorithms by listing as a table | +| [FHIR-43008](https://jira.hl7.org/browse/FHIR-43008) | Clarify “jti” reuse is permitted after expiration of any previous JWTs using same value | +| [FHIR-43014](https://jira.hl7.org/browse/FHIR-43014) | Correct status code to be returned by server when community is not recognized or not supported | +| [FHIR-43021](https://jira.hl7.org/browse/FHIR-43021) | Add missing hyperlinks for certain UDAP profiles | +| [FHIR-43048](https://jira.hl7.org/browse/FHIR-43048) | Clarify servers must respond to GET requests for metadata | +| [FHIR-43116](https://jira.hl7.org/browse/FHIR-43116) | Clarify that registration updates are requested within the context of the client’s trust community | +| [FHIR-43121](https://jira.hl7.org/browse/FHIR-43121) | Remove duplicated requirements for “iss” parameter in software statement | +| [FHIR-43554](https://jira.hl7.org/browse/FHIR-43554) | Clarify allowed registration claims returned by server may be different than claims submitted in software statement | +| [FHIR-51423](https://jira.hl7.org/browse/FHIR-43121) | State IG FMM maturity level | diff --git a/docs/specifications/TEFCA/rce-ig-v1.12.0-ai-friendly.md b/docs/specifications/TEFCA/rce-ig-v1.12.0-ai-friendly.md new file mode 100644 index 000000000..6a5008154 --- /dev/null +++ b/docs/specifications/TEFCA/rce-ig-v1.12.0-ai-friendly.md @@ -0,0 +1,410 @@ +# Recognized Coordinating Entity (RCE) Implementation Guide v1.12.0 + +> **Package**: `sequoia.fhir.us.rce#1.12.0` +> **FHIR Version**: R4 (4.0.1) +> **Status**: Active as of 2026-03-12 +> **Publisher**: Recognized Coordinating Entity (RCE) — The Sequoia Project +> **Official URL**: https://sequoiaproject.org/fhir/rce/ImplementationGuide/sequoia.fhir.us.rce + +--- + +## 1. Overview + +This RCE Directory Service Implementation Guide is used by Qualified Health Information Networks (QHINs) to share identifying information of QHINs, Participants, and Subparticipants, including relevant Child entities, as part of the TEFCA (Trusted Exchange Framework and Common Agreement) ecosystem. + +The RCE Directory Service is only accessible to QHINs, and is shared by QHINs to their own Participants and Subparticipants according to Section 8 of the Common Agreement. + +### Dependencies + +| Dependency | Package | Version | +|---|---|---| +| HL7 Terminology | `hl7.terminology.r4` | 7.1.0 | +| HL7 Extensions | `hl7.fhir.uv.extensions.r4` | 5.2.0 | +| Sequoia Healthcare Directory | `sequoia.fhir.us.sphd` | dev | +| UDAP Security | `hl7.fhir.us.udap-security` | 1.1.0 | +| US Core | `hl7.fhir.us.core` | STU4 (4.0.0) | + +### Organization Hierarchy + +The RCE Directory models a hierarchical structure of healthcare organizations in the TEFCA network: + +``` +RCE Directory +└── QHIN (Qualified Health Information Network) — top level, no parent + ├── Participant — must be part of exactly one QHIN + │ ├── Subparticipant — can be part of QHIN, Participant, or another Subparticipant + │ │ ├── Subparticipant (nested) + │ │ └── Child — leaf-level components (facilities, locations, etc.) — no endpoints + │ └── Child + └── Child +``` + +--- + +## 2. Specification + +### 2.1 API Key + +Access to the RCE Directory requires a valid API Key, unique per client and environment (VAL key cannot be used in PROD). + +**Query parameter format:** +``` +GET http://BASE-URL/Organization/?_apiKey=1234 +``` + +**Authorization header format:** +``` +Authorization: api-key 1234 +``` + +Contact `techsupport@sequoiaproject.org` to obtain keys. + +### 2.2 Actors + +| Actor | Role | Description | +|---|---|---| +| **RCE Requestor** | Read-only client | Retrieves organization data from the RCE Directory | +| **RCE Submitter** | Write-enabled client | Supplies organization data to the RCE Directory | +| **RCE Responder** | Server | Responds to data access requests and submissions. Also called "RCE Server" or "RCE Directory" | + +### 2.3 General Guidance + +- All Organization profiles inherit from Sequoia Organization → US Core Organization +- **Must Support** obligations follow [US Core conformance expectations](http://hl7.org/fhir/us/core/STU4/conformance-expectations.html#must-support-elements) +- **Contained Resources**: Currently, Endpoint and Location resources are only available as contained resources within Organization (not queryable separately). However, implementers should not rely on this — check if `Organization.endpoint` starts with `#` (contained) or is a full URI (standalone reference) + +--- + +## 3. Resource Profiles + +### 3.1 RCE Organization (Abstract Base) + +| | | +|---|---| +| **URL** | `https://sequoiaproject.org/fhir/rce/StructureDefinition/RCE-Organization` | +| **Base** | `SequoiaOrganization` → `US Core Organization` | +| **Abstract** | Yes — do not instantiate directly | + +This abstract profile defines shared constraints for all Organization profiles in this IG. + +**Key Constraints:** + +| ID | Severity | Rule | +|---|---|---| +| `rce-o1` | Warning | Organizations with Purpose of Use `T-TREAT` or `T-TRTMNT` must have an NPI identifier | +| `rce-o2` | Error | Must have `Organization.type:sequoiaorgtype` of Child, Participant, Subparticipant, or QHIN | + +**Key Element Requirements:** +- `identifier` — min 2 (must include TEFCAID) +- `identifier:TEFCAID` — exactly 1..1 +- `telecom:phone` — max 1 +- `telecom:email` — max 1 +- `address` — min 1, sliced by country +- `address.text` — required, must-support +- `address.country` — required +- `address:united-states-address` — min 1, must-support; requires `city`, `state` (required binding to USPS codes), `postalCode`; `country` fixed to "US" +- `contact.purpose` — required, must-support +- `contact.name` — required, must-support +- `contact.telecom` — min 2 (must include phone + email) +- `partOf` — references `RCE-Organization` +- `endpoint` — references `RCE-Endpoint` +- `extension:organization-node-type` — must-support + +**Purposes of Use** (extension on Organization): Bound to `RCEPurposeVS` (required strength). Defines purposes for which the organization initiates requests. + +### 3.2 QHIN + +| | | +|---|---| +| **URL** | `https://sequoiaproject.org/fhir/rce/StructureDefinition/QHIN` | +| **Base** | `RCE-Organization` | + +Represents a Qualified Health Information Network. One QHIN Organization per Designated QHIN. + +**Differential from RCE-Organization:** +- `type:sequoiaorgtype` — pattern: `OrganizationType#QHIN` +- `partOf` — **max 0** (QHINs are top-level, no parent) +- `contact` — min 1 + +### 3.3 Participant + +| | | +|---|---| +| **URL** | `https://sequoiaproject.org/fhir/rce/StructureDefinition/Participant` | +| **Base** | `RCE-Organization` | + +Represents a Participant as defined in the Common Agreement. Can parent Subparticipant and Child organizations. + +**Differential from RCE-Organization:** +- `extension` — min 2 +- `extension:org-managing-org` — min 1 (required) +- `type:sequoiaorgtype` — pattern: `OrganizationType#Participant` +- `partOf` — min 1, targets only `QHIN` (must be part of exactly one QHIN) + +### 3.4 Subparticipant + +| | | +|---|---| +| **URL** | `https://sequoiaproject.org/fhir/rce/StructureDefinition/SubParticipant` | +| **Base** | `RCE-Organization` | + +Represents a Subparticipant as defined in the Common Agreement. Can parent Subparticipant and Child organizations. + +**Differential from RCE-Organization:** +- `extension` — min 2 +- `extension:org-managing-org` — min 1 (required) +- `type:sequoiaorgtype` — pattern: `OrganizationType#Subparticipant` +- `partOf` — min 1, targets `QHIN`, `Participant`, or `SubParticipant` + +### 3.5 Child + +| | | +|---|---| +| **URL** | `https://sequoiaproject.org/fhir/rce/StructureDefinition/Child` | +| **Base** | `RCE-Organization` | + +Represents individual components (member organizations, facilities, locations) that make up a QHIN, Participant, or Subparticipant. **A Child cannot be an endpoint (node)** — it can be part of an endpoint but does not meet the definition of one. + +**Differential from RCE-Organization:** +- `extension` — min 2 +- `extension:org-managing-org` — min 1 (required) +- `extension:organization-node-type` — **max 0** (not allowed on Child) +- `type:sequoiaorgtype` — pattern: `OrganizationType#Child` +- `partOf` — min 1, targets `QHIN`, `Participant`, `SubParticipant`, or `Child` +- `endpoint` — **max 0** (Children cannot have endpoints) + +### 3.6 RCE Endpoint + +| | | +|---|---| +| **URL** | `https://sequoiaproject.org/fhir/rce/StructureDefinition/RCE-Endpoint` | +| **Base** | `SequoiaEndpoint` | + +Represents endpoints provided by Organizations participating in TEFCA exchange. Endpoints can use any QTF-supported protocol (e.g., IHE XCA) and need not be FHIR endpoints. + +**Differential:** +- `extension:purposesofuse` — bound to `RCEPurposeVS` (required). Defines purposes for which the endpoint will receive/respond to requests +- `managingOrganization` — references `RCE-Organization` + +--- + +## 4. Extensions (Defined Externally) + +All defined in the Sequoia Project Healthcare Directory IG: + +| Extension | Used On | Description | +|---|---|---| +| **Domains** | Organization | Which Sequoia directory the org participates in (CQ, eHx, RCE) | +| **Initiator Only** | Organization | Indicates org meets an Initiator Only exception and has no Endpoints for a Use Case | +| **OrgManagingOrg** | Organization | Tied to API key access — controls which org can create/update/delete the resource | +| **Purposes of Use** | Organization, Endpoint | On Endpoint: purposes for receiving/responding. On Organization: purposes for initiating requests | +| **State of Operation** | Organization | Repeatable; each instance is a US state/province where the org operates | +| **Use Cases** | Organization | Which TEFCA Use Case(s) the org participates in | + +--- + +## 5. Operations + +### 5.1 Activate Hierarchy +- **URL**: `https://sequoiaproject.org/fhir/rce/OperationDefinition/activate-hierarchy` +- **Scope**: Instance-level on Organization +- **Description**: Sets `Organization.active = true` for the specified Organization and all descendants (via `partOf`) +- **Output**: `List` of Organizations changed + +### 5.2 Deactivate Hierarchy +- **URL**: `https://sequoiaproject.org/fhir/rce/OperationDefinition/deactivate-hierarchy` +- **Scope**: Instance-level on Organization +- **Description**: Sets `Organization.active = false` for the specified Organization and all descendants (via `partOf`) +- **Output**: `List` of Organizations changed + +### 5.3 Update TEFCAID +- **URL**: `https://sequoiaproject.org/fhir/rce/OperationDefinition/update-tefcaid` +- **Scope**: Instance-level on Organization +- **Description**: Updates the TEFCAID identifier for the specified Organization and all Child descendants (via `partOf`) +- **Input**: `tefcaid` (string/token) — expressed as `system|value` (e.g., `urn:ietf:rfc:3986|[uuid]`) or value only +- **Output**: `List` of Organizations changed + +--- + +## 6. Examples + +### 6.1 QHIN Example + +A top-level QHIN organization with no parent (`partOf` absent). + +```json +{ + "resourceType": "Organization", + "id": "QHIN-example", + "meta": { + "profile": ["https://sequoiaproject.org/fhir/rce/StructureDefinition/QHIN"] + }, + "contained": [{ + "resourceType": "Location", + "id": "orgloc", + "position": { "longitude": -97.7047386, "latitude": 30.4159542 } + }], + "extension": [ + { + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/Domains", + "valueCoding": { + "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/Domains", + "code": "RCE" + } + }, + { + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/UseCases", + "valueCodeableConcept": { + "coding": [{ + "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/EndpointUseCaseCodes", + "code": "QueryBasedDocumentExchange" + }] + } + }, + { + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/org-managing-org", + "valueReference": { "reference": "Organization/QHIN-example" } + } + ], + "identifier": [ + { + "type": { "coding": [{ "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/SequoiaIdentifierCodes", "code": "HCID" }] }, + "system": "urn:ietf:rfc:3986", + "value": "urn:oid:5.5.5.5" + }, + { + "type": { "coding": [{ "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/SequoiaIdentifierCodes", "code": "TEFCAID" }] }, + "system": "urn:ietf:rfc:3986", + "value": "urn:oid:5.5.5.5" + } + ], + "active": false, + "type": [{ "coding": [{ "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/OrganizationType", "code": "QHIN" }] }], + "name": "RCE QHIN Example", + "address": [{ + "extension": [{ + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/OrgLocation", + "valueReference": { "reference": "#orgloc" } + }], + "use": "work", "type": "both", "text": "Primary", + "line": ["200 Main St."], "city": "Austin", "state": "TX", "postalCode": "2472", "country": "US" + }], + "contact": [{ + "purpose": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/contactentity-type", "code": "ADMIN" }] }, + "name": { "use": "official", "text": "Family, Given" }, + "telecom": [ + { "system": "phone", "value": "555-555-5555", "use": "work" }, + { "system": "email", "value": "test21@test.com", "use": "work" } + ] + }] +} +``` + +### 6.2 Endpoint Example (Contained in Participant) + +RCE Endpoints are typically contained resources within Organization instances: + +```json +{ + "resourceType": "Endpoint", + "id": "RCE-orgEndpoint", + "extension": [ + { + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/Transaction", + "valueCodeableConcept": { + "coding": [{ "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/TransactionCodes", "code": "FHIR REST" }] + } + }, + { + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/Version", + "valueString": "4.0.1" + }, + { + "url": "https://sequoiaproject.org/fhir/sphd/StructureDefinition/Roles", + "valueCodeableConcept": { + "coding": [{ "system": "http://snomed.info/sct", "code": "112247003" }] + } + } + ], + "status": "active", + "connectionType": { + "system": "https://sequoiaproject.org/fhir/sphd/CodeSystem/EndpointConnectionTypeCodes", + "code": "hl7-fhir-rest" + }, + "name": "FHIR REST Endpoint", + "managingOrganization": { "reference": "Organization/1.2.3.4.5" }, + "payloadType": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/endpoint-payload-type", "code": "any" }] }], + "address": "https://example.org/fhir" +} +``` + +### 6.3 Hierarchy Scenario Examples + +The IG includes detailed examples illustrating TEFCA network topologies described in SOP section 4.2: + +**Scenario A — QHIN A (Fully Federated)** +``` +QHIN A (fully federated — no centralized data, passes all transactions down) +├── Participant 1 (maintains eMPI/RLS, returns per-Subparticipant patient IDs, passes Queries down) +│ ├── Sub Z (Subparticipant — sees Document Query/Retrieval, Responds independently via XCA) +│ └── Sub Y (Subparticipant — sees Document Query/Retrieval, Responds independently via XCA) +└── Participant 2 (maintains complete clinical data repository, Responds for all Children) + └── Sub X (Child — never sees or Responds to TEFCA transactions directly) +``` + +**Scenario B — QHIN B (Centralized eMPI/RLS)** +``` +QHIN B (maintains eMPI/RLS, returns consolidated patient ID + aggregated doc list, passes Queries down) +└── Participant 3 (fully federated Passthrough Node, passes all transactions down) + ├── Sub W (Subparticipant — Passthrough Node, passes all transactions to Sub U) + │ └── Sub U (Subparticipant — sees Document Retrieval, Responds independently via XCA) + └── Sub V (Subparticipant — sees Document Retrieval, Responds independently via XCA) +``` + +--- + +## 7. Key Code Systems & Value Sets + +| Name | URL | Usage | +|---|---|---| +| OrganizationType | `https://sequoiaproject.org/fhir/sphd/CodeSystem/OrganizationType` | Values: QHIN, Participant, Subparticipant, Child | +| SequoiaIdentifierCodes | `https://sequoiaproject.org/fhir/sphd/CodeSystem/SequoiaIdentifierCodes` | Values include: HCID, TEFCAID, AAID | +| Domains | `https://sequoiaproject.org/fhir/sphd/CodeSystem/Domains` | Values: CQ, eHx, RCE | +| EndpointUseCaseCodes | `https://sequoiaproject.org/fhir/sphd/CodeSystem/EndpointUseCaseCodes` | e.g., QueryBasedDocumentExchange | +| TransactionCodes | `https://sequoiaproject.org/fhir/sphd/CodeSystem/TransactionCodes` | e.g., FHIR REST, XCA Query, XCA Retrieve | +| EndpointConnectionTypeCodes | `https://sequoiaproject.org/fhir/sphd/CodeSystem/EndpointConnectionTypeCodes` | e.g., hl7-fhir-rest | +| RCEPurposeVS | `https://sequoiaproject.org/fhir/sphd/ValueSet/RCEPurposeVS` | Required binding for PurposesOfUse extension on RCE resources | + +--- + +## 8. Profile Inheritance Chain + +``` +FHIR R4 Organization +└── US Core Organization (hl7.fhir.us.core STU4) + └── Sequoia Organization (sequoia.fhir.us.sphd) + └── RCE Organization (abstract) ← this IG + ├── QHIN + ├── Participant + ├── Subparticipant + └── Child + +FHIR R4 Endpoint +└── Sequoia Endpoint (sequoia.fhir.us.sphd) + └── RCE Endpoint ← this IG +``` + +--- + +## 9. Change Log (Recent) + +| Date | Version | Summary | +|---|---|---| +| 2026-03-12 | 1.12.0 | Updated to Sequoia base IG 1.12.0. OrganizationNodeType extension now must-support on all RCE Orgs except Child (where it's prohibited) | +| 2026-02-12 | 1.11.0 | Added constraint rce-o2: Organization.type must be Child/Participant/Subparticipant/QHIN | +| 2026-01-20 | 1.10.0 | Added constraint rce-o1: NPI required when Purpose of Use is T-TREAT or T-TRTMNT | +| 2025-12-22 | 1.9.0 | Added FHIRIGConformance extension examples, fixed typos | +| 2025-09-30 | 1.8.1 | Added 3 OperationDefinitions (Activate/Deactivate Hierarchy, Update TEFCAID). Updated examples with transaction types and endpoints | +| 2025-06-11 | 1.6.1 | Added 11 SOP 4.2 scenario examples. US address state binding changed to required | +| 2025-02-26 | 1.4.1 | Child.endpoint set to 0..0. TEFCAID made required (1..1) | +| 2023-06-30 | 1.0.0 | Initial Release | diff --git a/docs/specifications/UDAP.org/README.md b/docs/specifications/UDAP.org/README.md new file mode 100644 index 000000000..ab4611df0 --- /dev/null +++ b/docs/specifications/UDAP.org/README.md @@ -0,0 +1,35 @@ +# UDAP.org Specifications + +**Source:** https://www.udap.org +**Downloaded:** 2026-04-01 + +The Unified Data Access Profiles (UDAP) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP's first applications are in the healthcare sector, but the profiles are not healthcare specific. + +## Specifications + +| Specification | Version | File | +|---------------|---------|------| +| [JWT-Based Client Authentication](udap-jwt-client-auth.md) | STU 1 | Asymmetric cryptography for client authentication at token endpoint | +| [Tiered OAuth for User Authentication](udap-user-auth.md) | STU 1 | Scalable cross-organizational user authentication via trusted IdPs | +| [Dynamic Client Registration](udap-dynamic-client-registration.md) | STU 1 | PKI-based dynamic client registration extending RFC 7591 | +| [Mutual TLS Client Authentication](udap-tls-client-auth.md) | STU 1 | Client authentication during TLS handshake | +| [Certifications and Endorsements](udap-certifications-and-endorsements.md) | STU 1 | Third-party validation of client applications | +| [Client Authorization Grants](udap-client-authorization-grants.md) | DRAFT | JWT-based client authorization assertions | +| [Server Metadata](udap-server-metadata.md) | STU 1 | Publishing capabilities and endpoints for discovery | + +## Conversion Notes + +These markdown files are **raw automated conversions** from the original HTML source pages at https://www.udap.org using the Python `html2text` library. All normative text has been preserved verbatim. No content has been summarized, paraphrased, or editorially modified. Minor formatting artifacts from the HTML-to-markdown conversion may be present. + +## Key Concepts + +- **Trust Communities**: Groups of organizations sharing common trust anchors (root CAs) +- **X.509 PKI**: All trust is rooted in X.509 certificate chains with revocation checking +- **Software Statements**: Signed JWTs containing client registration metadata +- **Authorization Assertions (AzA)**: Signed JWTs conveying authorization grants +- **Signed Endpoints**: Server metadata JWTs for endpoint authenticity verification + +## Contact + +- Email: collaborate@udap.org +- Google Group: https://groups.google.com/forum/#!forum/udap-discuss/join diff --git a/docs/specifications/UDAP.org/udap-certifications-and-endorsements.md b/docs/specifications/UDAP.org/udap-certifications-and-endorsements.md new file mode 100644 index 000000000..09c2daf40 --- /dev/null +++ b/docs/specifications/UDAP.org/udap-certifications-and-endorsements.md @@ -0,0 +1,210 @@ + + + +# **UDAP Certifications and Endorsements for Client Applications** + +**Version STU 1** + +UDAP supports portable electronic client certifications that can be used by a Certifier to express verified attributes about a client application developer or a specific client application. A Certification can also be used to express an endorsement of a client application developer or application. Certifications can be signed by third parties that certify or endorse developers or applications, or signed by the developer of a client application to self-declare conformance to a set of published criteria. + +A client application developer can provide one or more certifications to an OAuth Server when registering its client application. This includes both manual and dynamic client registration processes. The OAuth Server can use the information in the certifications to determine whether or not to accept a client registration request. For authorization flows that interact with client app end users, the OAuth Server can also use the information in the certifications to inform the end user about the client. + +Certifications are constructed as signed JWTs. These JWTs allow authentication of a Certifier using standard PKIX tools. Before accepting a certification, the Authorization Server validates the digital signature on the certification, and evaluates the trust chain for the Certifier’s X.509 certificate used to perform this validation. The certification is accepted only if the certification is valid and the Certifier’s certificate is trusted. + +The following steps define the workflow for a Certifier to generate a signed certification JWT: + +1\. The Certifier prepares a certification for submission. The certification serves three purposes: it provides the necessary metadata for registration, it establishes the Certifier’s control of a private key, and it provides the digital certificate needed to validate the signature on a certification and establish trust. The certification is a signed JWT containing the following claims: + +iss | string, Certifier’s unique identifying URI +---|--- +sub | string, client’s unique identifying URI (binds to SAN:uniformResourceIdentifier in Client App certificate). For self-signed certifications, this is the same as the iss value. +aud | string, registration endpoint URL of Authorization Server (optional, single valued or array). If absent, this certification is intended for all audiences. +exp | number, expiration time (max 3 years, must not expire after certificate) +iat | number, issued at time +jti | string, token identifier that uniquely identifies this JWT until the expiration time +certification_issuer | string; the entity that operates the certification program (required if certification is not self-signed, omit if self-signed) +certification_name | string; short name for certification (required) +certification_logo | string (optional); URL pointing to logo for this certification, e.g. seal +certification_description | string; longer description of what this certification entails (optional) +certification_uris | array of strings; each URI identifies a certification program or set of criteria. This should be a resolvable URL where more information can be obtained. (optional; required if certification is self-signed) +certification_status_endpoint | string (optional); URL of status endpoint operated by the Certifier (see section 8); omit if self-signed +is_endorsement | boolean (optional, default: false); true if this certification represents an endorsement of the Client App by the issuer. +developer_name | string (optional) +developer_address | JSON object, as per OIDC Core 1.0 Section 5.1.1 +client_name | string, as per RFC 7591 +software_id | string, as per RFC 7591 (recommended) +software_version | string, as per RFC 7591 (optional) +client_uri | string, as per RFC 7591 +logo_uri | string, as per RFC 7591 +tos_uri | string, as per RFC 7591 +policy_uri | string, as per RFC 7591 +contacts | array of strings, as per RFC 7591, further constrained as follows: each array element MUST be a valid URI with mailto or https scheme so that AS operator can contact client app developer +launch_uri | string, for SMART app launch with EHR launch flow, requires scope includes launch +redirect_uris | array of strings, as per RFC 7591, except as noted; an array of fully specified redirection URIs for the client (conditional). MUST be absent if grant_types = client_credentials. Note: To support the RFC 8252 requirement that a native mobile app use a different redirection URI for every Authorization Server, the Certifier may include the special character * in the URI as a wildcard for a single path component or query parameter value, e.g. https://app.example.com/redirect/* or https://app.example.com/redirect?server=*. For URIs that contain literal asterisk characters, these characters should be URL-encoded as “%2A”; the Authorization Server MUST NOT interpret such a URL-encoded asterisk as a wildcard symbol. For a given Authorization Server, the client MUST register one or more complete redirection URIs with the Authorization Server that match this pattern; each registered redirect_uri MUST be fully specified and MUST NOT contain any wildcard symbols, even if the certification includes a wildcard symbol. +ip_allowed | array of strings of the form ip, ip1-ip2, or ip/CIDR (optional); origin IP to connect to token endpoint, e.g. ["198.51.100.0/24", "203.0.113.55"] +grant_types | array of strings, as per RFC 7591; e.g. authorization_code, refresh_token, client_credentials (optional) +response_types | array of strings, as per RFC 7591; code (omit for client_credentials) (optional) +scope | string containing space separate list of permitted scopes, as per RFC 7591; optional +token_endpoint_auth_method | string, as per RFC 7591 (optional); RFC 7591 defines the values: none, client_secret_post, and client_secret_basic. The additional value private_key_jwt may also be used. +jwks | string, as per RFC 7591 (optional); locks this certification to a specific client key or keys. Note that jwks_uri MUST NOT be used. The client must prove possession of this key during registration and during authentication. To facilitate key rollover, binding using the sub claim URI is preferable to binding to a specific key. + +  + +Inclusion of one or more of the optional client application parameters indicates that the Certifier wishes to limit this certification to the Client app for certain software ID and/or version, certain redirection URIs, grant types, response types, and/or endpoint authorization methods. When the restriction parameter is an array, a client registration requesting a subset of the array values would also be permitted. For example if the grant_types array in a certification contains two grant types, a Client App registration requesting only one of those grant types would also match the restrictions of that certification. + +2\. The JOSE Header for the certification contains the following key/value pairs: + +{ +"alg" : "rs256", +"x5c" : [cert1, cert2, …], (cert1 is required; remainder of chain is optional) +"x5u" : valid URI (optional) +} + +The x5c claim contains the Certifier’s certificate chain as an array of one or more elements, each containing a Base64 encoded representation of the DER encoded X.509 certificate. The leaf certificate (cert1) contains the public key corresponding to the private signing key used by the Certifier to digitally sign the certification. + +The certification is signed and assembled using JWS compact serialization as per RFC 7515. + +3\. Example Third Party App Certification. Certain information about the example app and its developer has been validated by a third party: + +{ +"iss": "http://identityprovider.example.org/certifications", +"sub": "http://appdeveloper.example.com/apps/superapp/v1", +"exp": 1562961473, +"iat": 1531425447, +"jti": "C0hlwc7r+3/t0qoK1G4G/B0hsyE7xR4PGiRfWQBZXY7e", +"certification_issuer": "Some Sample Verification Company", +"certification_name": "ID Validated Developer - Silver", +"certification_logo": "https://identityprovider.example.org/images/id-verified.jpg", +"certification_description": "The identity of the app developer has been verified at the Silver level. ", +"certification_uris": ["https://acme.example.org/programs/id-verify"], +"certification_status_endpoint": "https://identityprovider.example.org/status", +"is_endorsement": false, +"developer_name": "AppDeveloper Company", +"client_name": "SuperApp v.1", +"redirect_uris": ["https://appdeveloper.example.com/apps/superapp/redirect"], +"grant_types": ["authorization_code"], +"response_types": ["code"] +} + +In this example, the issuer is limiting this certification to the setting where the Client App requests registration using the client app parameters listed in the final four elements of the JWT. + +4\. Example Declaration of Conformance by an app developer. The app developer is declaring conformance of its app to a set of published security and privacy criteria: + +{ +"iss": "http://appdeveloper.example.com/apps/superapp/v1", +"sub": "http://appdeveloper.example.com/apps/superapp/v1", +"exp": 1562961584, +"iat": 1531425558, +"jti": "0hsyE7xR4PGiRfWQBZXY7e C0hlwc7r+3/t0qoK1G4G/S", +"certification_name": "ABCD Application Security & Privacy Self-Declaration", +"certification_uris": ["https://abcd.example.org/criteria/security-2019.1", +"https://abcd.example.org/criteria/privacy-2019.1"], +"policy_uri": "https://appdeveloper.example.com/privacy-policy.html", +"contacts": ["mailto:support@example.com"], +"grant_types": ["authorization_code"] +} + +In this example, the developer is limiting its declaration of conformance to the workflow where its app uses the authorization code flow to obtain access. In this example, the criteria are published by a different example organization (ABCD) that has established a certification program intended for this purpose. These criteria could take the form of simple checklists, or more complex self-assessments. The publisher of the criteria SHOULD define the specific conditions under which an app developer can assert a particular certification URI, e.g. minimum requirements or percent of criteria met, other parameters that must be included such as policy_uri, developer contact email address, etc. + +5\. Example Third Party “Endorsement” Certification. This example app has earned the “ACME Seal of Approval” certification and is endorsed by ACME for use by its members: + +{ +"iss": "http://acme.example.org/certifications", +"sub": "http://appdeveloper.example.com/apps/superapp/v1", +"exp": 1562961473, +"iat": 1531425447, +"jti": "mQtC0hlwc7r+3/t0qoK1G4G/B0hsyE7xR4PGiRfWQBZX", +"certification_issuer": "ACME", +"certification_name": "Seal of Approval", +"certification_logo": "https://acme.example.org/images/seal-of-approval.jpg", +"certification_description": "ACME has evaluated this software and recommends it use by its members.", +"certification_uris": ["https://acme.example.org/programs/acme-certification"], +"is_endorsement": true, +"client_name": "AppDeveloper SuperApp v.1", +"redirect_uris": ["https://appdeveloper.example.com/apps/redirect"], +"grant_types": ["authorization_code", "refresh_token"], +"response_types": ["code"] +} + +In this example, the issuer is limiting this endorsement to the setting where the Client App requests registration using the client app parameters listed in the final four elements of the JWT. + +6\. Processing of Certifications and Endorsements + +This section describes the processing of certifications and endorsements submitted by a Client App as part of a dynamic registration request, such as with the UDAP Dynamic Client Registration profile. For each certification object included by the client in the certifications array of the registration request, the Authorization Server SHALL validate each certification as per steps 1-5 of the following procedure. If a certification is rejected for any of the reasons listed below, the Authorization server MAY continue processing any remaining certifications or MAY deny the registration request. + +6.1 For each certification, the Authorization server first validates the digital signature for the certification using the public key extracted from cert1 in the x5c parameter of the JOSE header of that certification. Note that cert1 is the certificate issued to the Certifier. If the signature for the certification cannot be validated, the certification is rejected. + +6.2 The Authorization Server MAY evaluate the certification_uris array to determine if the certification is acceptable. How the Authorization Server determines which certification_uris are acceptable is a matter of local policy. For example, the Authorization Server may choose to accept all certifications from a given certification Certifier, accept only certifications from Certifiers with certificates issued from a set of trusted certificate issuers, or accept only those certifications that include a specific URI in the certification_uris array. If the certification is not acceptable, the certification is rejected. + +6.3 The Authorization Server validates the iss, sub, aud, exp, iat, and jti values in the certification. The iss value MUST match a uriName entry in the Subject Alternative Names extension of the Certifier’s certificate. The sub value MUST match the Client’s identifying URI submitted by the Client in the registration request. The aud value, if present, MUST contain the Authorization Server’s registration endpoint URL, and the certification MUST be unexpired. A maximum certification lifetime of 3 years is RECOMMENDED. If these parameters cannot be validated by the Authorization Server, the certification is rejected. + +6.4 The Authorization Server attempts to construct a valid certificate chain from the Certifier’s certificate (cert1) to an anchor certificate trusted by the Authorization Server using standard X.509 chain building techniques and path validation as defined in RFC 5280, including certificate validity and revocation status checking. To assist the Authorization Server, the Certifier MAY submit a complete certificate chain in the x5c parameter of the JOSE header of the certification. The Authorization Server MAY use additional certificates that were not included by the Certifier to construct a trusted chain (e.g. from its own certificate cache or discovered via the X.509 AIA mechanism). If a trusted chain cannot be built and validated by the Authorization Server, the certification is rejected. + +6.5 The Authorization Server validates that the registration parameters requested by the Client in the registration request match the registration parameters included in the certification. If the certification includes a registration parameter, and the registration parameters requested by the Client do not match the values in the certification, the certification is rejected. + +6.6 After processing of all certifications is completed, the Authorization server MAY reject the registration request if one or more of the requested registration parameters has not been included in at least one accepted certification. + +6.7 If the registration request is granted, the Authorization Server MUST include a certifications parameter in the response with a value of an array containing the accepted certifications. Certifications that were rejected MUST NOT be included in this array. + +6.8 If the registration request is denied because one or more certifications was rejected, the Authorization Server MUST return an appropriate error code in the registration response as per section 3.2.2 RFC 7591\. Rejections related to trust validation SHOULD use the code “unapproved_certification”. Rejections related to invalid signatures SHOULD use the code “invalid_certification”. The Authorization server MAY include an error_description parameter. + +7 Inclusion in Dynamic Client Registration requests + +Clients can submit one or more certifications in a Dynamic Client Registration request as per RFC 7591 by including the extension parameter “certifications” in the registration request with its value equal to an array of certification JWTs, and the extension parameter “udap” with a value of “1”. + +8 Revocation of Certifications + +An issuer of certifications MAY support status checking of certification by relying parties. The Certifier indicates this support by enabling a status endpoint and including the URL of this endpoint in the certification_status_endpoint parameter. If this parameter is included, the Certifier MUST respond to status requests submitted to this endpoint. The endpoint MUST be TLS protected. To request the status of a certification, the relying party makes an HTTP GET request to the endpoint and includes the URL-encoded value of the “jti” parameter and the value of the “exp” parameter included in the certification. To reduce unnecessary traffic, the relying party SHOULD NOT request the status of certifications that are expired, untrusted, or otherwise unacceptable to the relying party. + +For example, to request the status of the sample certification shown in Section 3 of this document, a relying party would make the following request: + +GET /status?jti=C0hlwc7r%2B3%2Ft0qoK1G4G%2FB0hsyE7xR4PGiRfWQBZXY7e&exp=1562961473 HTTP/1.1 +Host: identityprovider.example.org + + + +The Certifier MUST return a JSON object with a single key of “valid” and a boolean value. The value of true MUST be returned if and only if the Certifier issued a certification matching the jti and exp values submitted and the Certifier has determined the certification is still valid. If the certification has been revoked, has expired, or is deemed unacceptable or invalid by the issuer for any other reason, a value of FALSE must be returned. If the certification status endpoint is unreachable or an error code is returned, the relying party MUST NOT assume that the certification is still acceptable. + +Example response indicating that the certification is still acceptable: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ "valid": true } + + + +9 References + +Denniss, W., Bradley, J., “OAuth 2.0 for Native Apps”, BCP 212, RFC 8252, RFC Editor, October 2017. +Hardt, D., Ed., “The OAuth 2.0 Authorization Framework”, RFC 6749, RFC Editor, October 2012. +Jones, M., et al., “JSON Web Signature (JWS)”, RFC 7515, RFC Editor, May 2015. +Jones, M., et al., “JSON Web Token (JWT)”, RFC 7519, RFC Editor, May 2015. +Richer, J., Ed., “OAuth 2.0 Dynamic Client Registration Protocol”, RFC 7591, RFC Editor, July 2015. +Sakimura, N, et al. “OpenID Connect Core 1.0 incorporating errata set 1”, The OpenID Foundation, November 2014. + +10 Authors + +Luis C. Maas III, EMR Direct +Julie Maas, EMR Direct + +11 Acknowledgements + +The authors would like to thank the following individuals for their valuable feedback during the development of this document: Mark Scrimshire, Alan Viars, and David Gage. + +12 Notices + +Copyright ©2016-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Specification, in full and without modification, solely for the purpose of implementing the technology described in this Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +  + +  + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/docs/specifications/UDAP.org/udap-client-authorization-grants.md b/docs/specifications/UDAP.org/udap-client-authorization-grants.md new file mode 100644 index 000000000..8931a6336 --- /dev/null +++ b/docs/specifications/UDAP.org/udap-client-authorization-grants.md @@ -0,0 +1,209 @@ + + + +# **UDAP Client Authorization Grants using JSON Web Tokens** + +**DRAFT 2019-05-15** + +UDAP implements certificate-based client authorization grants using JSON Web Tokens as an extension to the OAuth 2.0 authorization framework defined in RFC 6749, based in part on the profiles defined in RFC 7521 and RFC 7523 for assertion-based authorization. To use this extension, the Client App obtains a digitally signed Authorization Assertion (AzA), and includes this assertion in the Client App’s request to the token endpoint. + +There are two general ways in which an App may obtain an Authorization Assertion: + +(a)    A resource owner or other authorized party may generate and sign an Authorization Assertion and provide it to a Client App so that the app may then access certain data. For example, this could be used as an alternative to following the authorization_code grant flow when the resource owner is not available in real time to interact with an OAuth Server, but is able to sign an Authorization Assertion in advance to grant a set of scopes to an app. In that case, the assertion would be submitted instead of a conventional authorization code. + +(b)   A privileged application or other requesting party may generate and sign an Authorization Assertion to communicate information that the OAuth Server requires in order to determine what data should be accessible. This includes self-signed assertions generated by the application. For example, an application may use an Authorization Assertion to communicate that the requested resources will be used for a specific purpose permitted by a data use agreement or will be made available to a certain class of users, or that the requests will be made on behalf of a particular organization or person. + +This authorization protocol is based upon standard public key infrastructure tools and is independent of the protocol used to authenticate the Client App. For example, Authorization Assertions can be used with any OAuth 2.0 client authentication mechanism supported by the token endpoint, including with HTTP Basic authentication, with JWT-based authentication as in the UDAP JWT-Based Client Authentication profile, with other authentication mechanisms supported by the token endpoint, or without client authentication in the case of public clients. + +Before granting an access token, the Authorization Server validates the digital signature on the AzA submitted by the Client App and evaluates the trust chain for the Assertion Signer’s X.509 certificate used to make its assertions. An access token is granted by the Authorization Server to a Client App only if the AzA is valid and the Assertion Signer’s certificate is trusted. This protocol MAY be used only in settings where the Assertion Signer is able to protect the private key used to sign AzAs, e.g. confidential clients for self-signed assertions, or digital signing systems using secure key custodians or secure hardware. + +The following steps define the workflow: + +1\. The Client App checks that the Authorization Server supports UDAP federation by retrieving the Authorization Server’s UDAP metadata from a well-known URL. + +GET https://resourceholder.example.com/.well-known/udap HTTP/1.1 + + + +Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"x5c" : ["{_cert1_}", "{_cert2_}", …] +} + +If the Authorization Server returns the 404 error code, then the Authorization Server does not advertise support of UDAP Client Authorization Grants protocol. The Authorization Server’s certificates may be discovered as part of this metadata request. The Client App MAY examine the certificates to determine whether the Authorization Server is part of a suitable trust community. + +2\. The Client App must be registered with the Authorization Server to use signed JWTs as Authorization Assertions. If the Authorization Server supports UDAP Dynamic Client Registration, the Client App MAY register with the AS using that protocol. If the AS does not support dynamic registration, it SHOULD supply another method for Client Apps to register to use this authorization grant mechanism. If the Client App intends to submit Authorization Assertions only for authorization, as in section 5.1, it should register to use the “urn:ietf:params:oauth:grant-type:jwt-bearer” grant type. If it intends to submit self-signed Authorization Assertions for both authorization and authentication, as in section 5.2, it should register to use the “client_credentials” grant type. + +3\. The Client App obtains or generates an Authorization Assertion (AzA) for inclusion in its request to the AS’s token endpoint. The AzA serves two purposes: it establishes the Assertion Signer’s control of a private key, and it provides the digital certificate needed to validate the signature and establish trust. + +The AzA is a signed JWT containing the following claims: + +iss: unique identifying URI of the Client Token Service for the Assertion Signer +sub: the authorized accessor for which the access token is being requested. In the case of self-signed assertions submitted by a privileged Client App, the value is equal to the client ID assigned to the Client app by the Authorization server. In other cases, this value represents an identifier for the subject recognized by the AS. +azp: the authorized party, equal to the Client App’s identifying URI (omit for self-signed assertions submitted by a Client App) +aud: token endpoint URI of the Authorization Server +exp: expiration time for the authorization expressed as seconds since epoch (should be as short-lived as practical for the use cases supported) +iat: issued at time expressed as seconds since epoch +jti: identifier used to identify replay of this assertion +resources: array of strings, the URIs of specific resources authorized by the Assertion Signer (optional); included when the Assertion Signer wishes to narrow access to a set of specific resources. Note that including a resource in this array does not override any access restrictions imposed by the AS based on the scopes granted, the identity of the Assertion Signer or the Client App, or other policy rules. +scope: string, space delimited list of scopes that may be requested by the Client App (optional); included when the Assertion Signer wishes to restrict the scopes granted to the set listed. Note that including a scope in this list does not override any other restrictions the AS may impose on the scopes available to the Client App. +extensions: JSON object, see below (optional) + +The JOSE Header for the AzA contains the following key/value pairs: + +alg : "RS256" +x5c : [cert1, cert2, …] (cert1 is required; remainder of chain is optional) +x5u : valid URI (optional) + +The x5c claim contains the Assertion Signer’s certificate chain as an array of one or more elements, each containing a base64 encoded representation of the DER encoded X.509 certificate. The leaf certificate (cert1) contains the public key corresponding to the private signing key used by the Client App to digitally sign the software statement. The AzA is signed and assembled using JWS compact serialization as per RFC 7515. + +4\. An Authorization Server MAY support extensions to this protocol using the extensions parameter. The extension names and their required formats are defined by the Authorization Server and are communicated in an out-of-band manner. An extension value can be any valid JSON entity. For example, an Assertion Signer might include the following extensions parameter in the assertion intended for an AS that requires the following information to process a token request: + +"extensions": { +"purpose_of_use" : "payment", +"query_initiator": {"name": "john smith", "user_id": "A98765"} +} + +5\. The client requests a token from the Authorization Server’s token endpoint + +There are two ways the Client App can use the Authorization Assertion: the assertion can be used for authorization only in conjunction with another authentication scheme as in section 5.1, or an assertion signed by the Client App can be used for both authorization and authentication as in section 5.2. + +5.1 Assertion used for Authorization only + +The client indicates that an Authorization Assertion will be used by including two parameters: “grant_type” with a value of “urn:ietf:params:oauth:grant-type:jwt-bearer” and “assertion” with a value equal to the JWS compact serialization of the signed AzA constructed in step 4. An additional extension parameter “udap” is also included with a string value of “1” to signal to the AS that version 1 of this protocol is being used. If required by the AS, the Client App MUST also include suitable authentication credentials in the request. + +In the following example, the Client App is submitting an assertion as an alternative to using the authorization_code grant type because the user is not available in real time to interact with the Authorization Server’s authorization endpoint for the authorization code flow. Instead, in the request to the token endpoint, the Client App submits an Authorization Assertion obtained separately from the resource owner and uses HTTP Basic authentication to authenticate itself to the Authorization Server. The resource owner first assembles the Authorization Assertion that is valid for one hour, shown here prior to Base64URL encoding and signing by the resource owner’s signing service: + +{ +"alg": "RS256", +"x5c": ["MIIGZjCCBE6gA… _remainder of Base64 encoded certificate omitted for brevity…_ "] +}.{ +"iss": "https://as.example.com/identity-service", +"sub": "https://as.example.com/identity/TupoJoWnXp1uBlo26A75", +"azp": "http://appdeveloper.example.com/apps/superapp/v1", +"aud": "https://as.example.com/token", +"exp": 1557841184, +"iat": 1557837584, +"jti": "1Ly1QFX9vtc-t1m1fV9oz8estCDXQ9mLhFvuxHbZhsM", +"scope": "read write" +} + +The Client App later submits the signed AzA provided by the resource owner in a request to the Authorization Server’s token endpoint: + +POST /token HTTP/1.1 +Host: as.example.com +Content-type: application/x-www-form-urlencoded +Authorization: Basic bXlDbGllbnRJRDpINDl4LXN0cm9uZy1wYXNzd29yZC1CJDYz + +grant_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer& +assertion=ewogICAiYWxnIjogIlJT… _remainder of AzA omitted for brevity_ …& +scope=read+write& +udap=1 + +Note that any suitable authentication protocol supported by the Authorization Server could be used instead of HTTP basic, or, as in the case of public clients, authentication might not be required. + +5.2 Assertion used for both Authorization and Authentication + +In the case where the client credentials grant flow is used by a privileged Client App acting on its own behalf, a single signed JWT can be used as both an Authorization Assertion and Authentication Token (AnT). The client indicates that this single assertion will be used for both purposes by including three parameters: “grant_type” with a value of “client_credentials”, “client_assertion_type” with a value of “urn:ietf:params:oauth:client-assertion-type:jwt-bearer” and “client_assertion” with a value equal to the JWS compact serialization of the signed AzA constructed in step 4. For this special case, the value of the AzA sub claim MUST be set to the client ID issued to the Client App by the AS. An additional extension parameter “udap” is also included with a string value of “1” to signal to the AS that version 1 of this protocol is being used. + +Note that this is a special case of the authentication process defined in the UDAP JWT-Based Client Authentication profile. The Authorization Assertion contains all of the required elements of the Authentication Token from that profile, plus the additional elements defined in this profile that are required for authorization. + +In the following example, the client app is requesting authorization to access health information from a hospital. The Client App first assembles the Authorization Assertion that is valid for 5 minutes, including additional information that the Authorization Server requires for auditing purposes using an extension defined by the Authorization Server, shown here prior to Base64URL encoding and signing by the Client App: + +{ +"alg": "RS256", +"x5c": ["MIIEczCCA1ugA… _remainder of Base64 encoded certificate omitted for brevity…_ "] +}.{ +"iss": "http://appdeveloper.example.com/apps/superapp/v1", +"sub": "myClientID", +"aud": "https://hospital.example.com/token", +"exp": 1557843252, +"iat": 1557843852, +"jti": "Q1E6g2PY91nmj5bSJJ-CZQ", +"extensions": { +"originating_organization": "2.16.840.1.113883.19.347473", +"originating_individual": "2.16.840.1.113883.19.347473.3.284856", +"purpose_of_use": "treatment" +} +} + +In the example, the hospital’s Authorization Server requires that the client app use the extensions parameter to identify the individual originating the request and the organization represented, using OIDs from a specific arc, and that the app also identify the purpose of the data request. To request an access token, the Client App submits the signed combined AzA/AnT in a request to the Authorization Server’s token endpoint: + +POST /token HTTP/1.1 +Host: hospital.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=client_credentials& +client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer& +client_assertion=ewogICAiYWxnIjogIlJT… _remainder of AzA/AnT omitted for brevity_ …& +scope=system%2FPatient.read +udap=1 + +In the example, the hospital’s Authorization Server also follows the authentication validation procedure from UDAP JWT-Based Client Authentication to authenticate the client app. + +6\. Authorization Server validates request + +This section describes the procedure used by the AS to validate a token endpoint request that contains an Authorization Assertion. + +6.1 The AS validates the digital signature on the AzA using the public key extracted from cert1 in the x5c parameter of the JOSE header. If the signature cannot be validated, the request is denied. + +6.2 The AS attempts to construct a valid certificate chain from the Assertion Signer’s certificate (cert1) to an anchor certificate trusted by the AS using conventional X.509 chain building techniques and path validation, including certificate validity and revocation status checking. The Assertion Signer SHOULD submit a complete certificate chain in its request. The AS MAY use additional certificates not included by the Assertion Signer to construct a chain (e.g. from its own certificate cache or discovered via the X.509 AIA mechanism). If a trusted chain cannot be built and validated by the AS, the request is denied. + +6.3 The AS validates the sub, azp, aud, exp, iat, and jti values within the AzA. The sub value MUST be an identifier for an entity (e.g. an end user or organization) that is recognized by the AS and is permitted by the AS to authorize access using an AzA. If the azp claim is present, then its value MUST match the client ID of the Client App as recognized by the AS. The aud value MUST contain the AS’s base URL, and the AzA MUST be unexpired. A maximum AzA lifetime of 60 minutes is RECOMMENDED. The AS MAY deny a request if the same AzA (as determined by the jti value) has been used in a previous registration request. + +6.4 If the extensions parameter is included, then the AS processes the extensions. The AS MAY ignore any extension names that it does not recognize. Alternatively, the AS MAY deny the request and return the error code “invalid_grant” as per section 7.2 if an unrecognized name is encountered. The “invalid_grant” error code SHOULD be used if any of the extension names required by the AS are not included in the AzA. + +6.5 If the scope parameter is included, then the AS MUST disallow any scopes requested by the Client App that are not listed in the Authorization Assertion. If a disallowed scope was requested, the AS MAY continue processing of the other requested scopes or it MAY return the error code “invalid_scope” as per section 5.2 of RFC 6749. If the assertion lists an invalid or unknown scope, the AS MAY deny the request and return the error code “invalid_grant” as per section 7.2. Note that for assertions signed by the Client App, the Client App MUST still request scopes in the usual manner required by the OAuth flow in use even if it includes a list of scopes in this assertion. + +6.6 The AS validates any other parameters in the request as per the requirements of the authentication mechanism in use, if any. If a parameter is invalid or a required parameter is missing, or if authentication of the Client App is required but is unsuccessful, then the request is denied. + +6.7 Support for the resources parameter by an AS is optional. If the assertion includes this parameter but the AS does not support this feature, the AS MUST deny the request and return the error code “invalid_request” as per section 7.2. Otherwise, if the resources parameter is included in the assertion, the AS MUST further narrow the scope of any access token granted to disallow access to resources that are not explicitly included in the list of resource URIs. Each resource URI listed MUST be treated as a fully specified URI, i.e. a listed resource URI does not allow access to resources that are located at subpaths of the listed URI or that contain additional query parameters. + +7\. Authorization responds to request + +7.1 If the request is granted, the Authorization Server returns a token response as per Section 5.1 of RFC 6749. For example: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"access_token": "example_access_token_issued_by_AS", +"token_type": "Bearer", +"expires_in": 3600 +} + +The AS MAY also return a refresh_token in its response. The expiration time of the access token SHOULD NOT significantly exceed the expiration time of the AzA. + +7.2 If the request is denied, the AS returns an error as per Section 5.2 of RFC 6749. Denials related to trust validation or invalid AzA signatures SHOULD use the “invalid_grant” code. Section 6 lists required or recommended error codes for other error conditions. The AS SHOULD also include a suitable error_description parameter in its response. For example: + +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ +"error": "invalid_grant", +"error_description": "The submitted authorization assertion has expired." +} + +8 Authors + +Luis C. Maas III, EMR Direct +Julie Maas, EMR Direct + +9 Notices + +Copyright ©2016-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Draft Specification, in full and without modification, solely for the purpose of implementing the technology described in this Draft Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Draft Specifications and Final Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +  + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/docs/specifications/UDAP.org/udap-dynamic-client-registration.md b/docs/specifications/UDAP.org/udap-dynamic-client-registration.md new file mode 100644 index 000000000..d9f9cba14 --- /dev/null +++ b/docs/specifications/UDAP.org/udap-dynamic-client-registration.md @@ -0,0 +1,159 @@ + + + +# **UDAP Dynamic Client Registration** + +**Version STU 1** + +Registration with an Authorization Server is typically required before a Client Application can access protected resources using the OAuth 2.0 authorization framework. For larger ecosystems with numerous clients and authorization servers, manual client registration requiring one-off enrollments or pairwise key exchange presents a significant barrier. Such communities can leverage a distributed system of authoritative information through the use of digital certificates to enable a scalable dynamic solution to client registration, allowing real-time trust assessment, validation of identifying attributes, and support for revocation of certificates. + +UDAP implements dynamic client registration using digital certificates as an extension to the OAuth 2.0 dynamic client registration protocol defined in RFC 7591. Use of this extension is requested by Client Apps by including a digitally signed software statement in their requests to the registration endpoint, where the signature is backed by an X.509 certificate. This framework also allows Client Apps to submit digitally signed certifications and endorsements provided by third parties as part of their registration request, either of which can be independently validated by the Authorization Server prior to allowing the registration of a Client App. + +This protocol is based upon standard Public Key Infrastructure tools. Before granting registration, the Authorization Server validates the digital signature on the software statement submitted by the Client App, and evaluates the trust chain for the Client App’s certificate. Client registration is permitted only if the software statement is valid and the Client App’s certificate is trusted. This protocol MAY be used only by Client Apps that are able to protect the private key used to sign software statements, e.g. confidential clients and certain native device apps. + +Note: The HTTP request and response examples presented below are _non-normative_. For readability purposes, line wraps have been added to some HTTP headers and request bodies, some headers have been omitted, and some parameters have not been URL encoded. + +Sections 1 through 5 define the steps of the workflow. Section 6 defines how previous registrations may be modified or cancelled. + +1\. The Client App checks that the Authorization Server supports UDAP by retrieving the Authorization Server’s UDAP metadata from a well-known URL. + +GET /.well-known/udap HTTP/1.1 +Host: resourceholder.example.com + + + +Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"x5c" : ["{_cert1_}", "{_cert2_}", …] +} + +If the Authorization Server returns an error code, then the Authorization Server does not support UDAP Dynamic Client Registration and the Client App should abort this workflow. + +2\. The Client App prepares a software statement for submission. The software statement serves three purposes: it provides the necessary metadata for registration, it establishes the Client App’s control of a private key, and it provides the digital certificate needed to validate the signature and establish trust. + +The software statement is a signed JWT containing the following claims: + +iss: Client App Operator’s unique identifying URI (identifying the holder of private key, also serves as the base URI for UDAP metadata including lookup of certificates) +sub: same as iss +aud: string, the Authorization Server’s registration endpoint URL +exp: number, expiration time (should be short-lived, max 5 minutes from iat) +iat: number, issued at time +jti: string, unique token identifier used to identify token replay +client_name: string +redirect_uris: array of URIs (required if grant_types includes authorization_code) +grant_types: array of strings, e.g. authorization_code, refresh_token, client_credentials +response_types: array of strings, e.g. code (omit for client_credentials) +token_endpoint_auth_method: string, fixed value: private_key_jwt +scope: string, space delimited list of requested scopes (optional) + +The Client MAY also include other optional parameters from section 2 of RFC 7591. + +The JOSE Header for the software statement contains the following key/value pairs: + +alg : "RS256" +x5c : [cert1, cert2, …] (cert1 is required; remainder of chain is optional) +x5u : valid URI (optional) + +The x5c claim contains the Client App’s certificate chain as an array of one or more elements, each containing a base64 encoded representation of the DER encoded X.509 certificate. The leaf certificate (cert1) contains the public key corresponding to the private signing key used by the Client App to digitally sign the software statement. The software statement is signed and assembled using JWS compact serialization as per RFC 7515. + +3\. The client requests registration by submitting the signed software statement to the Authorization Server’s registration endpoint. The additional extension parameter “udap” is also included with a string value of “1” to signal to the Authorization Server that version 1 of this protocol is being used, and that the included software statement conforms to this profile. Optionally, the Client App MAY submit certifications or endorsements provided by third parties by including the optional extension parameter “certifications” with a value of an array of one or more signed JWTs, each representing a separate certification or endorsement: + +POST /register HTTP/1.1 +Host: as.example.com +Content-type: application/json + +{ +"software_statement" : "{_signed software statement_}", +"certifications" : [_array of one or more signed JWTs_], +"udap" : "1" +} + +The client SHOULD include all requested registration parameters in the signed software statement, i.e. additional registration parameters SHOULD NOT appear at the top level of the submitted JSON object. If the client does include the same registration parameter twice, once at the top level and again in the signed software statement, then the Authorization Server MUST ignore the parameter included at the top level and process only the parameter included in the software statement. + +4\. Authorization Server validates request + +4.1 The Authorization Server validates the digital signature on the software statement using the public key extracted from cert1 in the x5c parameter of the JOSE header. If the signature cannot be validated, the request is denied. + +4.2 The Authorization Server attempts to construct a valid certificate chain from the Client’s certificate (cert1) to an anchor certificate trusted by the Authorization Server using conventional X.509 chain building techniques and path validation, including certificate validity and revocation status checking. The Client MAY submit a complete certificate chain in its request. The Authorization Server MAY use additional certificates not included by the Client to construct a chain (e.g. from its own certificate cache or discovered via the X.509 AIA mechanism). Authorization Servers SHOULD support the X.509 AIA mechanism for chain building. If a trusted chain cannot be built and validated by the Authorization Server, the request is denied. + +4.3 The Authorization Server validates the iss, sub, aud, exp, iat, and jti values in the software statement. The iss value MUST match a uriName entry in the Subject Alternative Names extension of the Client’s certificate. The sub value MUST match the iss value. The aud value MUST contain the Authorization Server’s registration endpoint URL, and the software statement MUST be unexpired. A maximum software statement lifetime of 5 minutes is RECOMMENDED. The Authorization Server MAY deny a request if the same software statement (as determined by the jti value) has been used in a previous registration request. + +4.4 The Authorization Server validates the registration parameters in the software statement. If a parameter is invalid or a required parameter is missing, the request is denied. + +4.5 If there has been a previous registration granted by the Authorization Server linked to the public key submitted by the Client for the requested grant type, the Authorization Server MAY cancel the previous registration. See section 6 for additional details. + +5\. Authorization Server responds to request + +5.1 If the request is granted, the Authorization Server returns a registration response as per Section 3.2.1 of RFC 7591. The top-level elements of the response SHALL include the client_id issued by the Authorization Server for use by the Client App, the software statement as submitted by the Client App, and all of the registration related parameters that were included in the software statement: + +HTTP/1.1 201 Created +Content-Type: application/json + +{ +"client_id": "example_client_id_issued_by_AS", +"software_statement": "{_the software statement as submitted by the client_}", +"client_name": string, +"redirect_uris": [_array of URIs_], +"grant_types": ["authorization_code"], +"response_types": ["code"], +"token_endpoint_auth_method": "private_key_jwt" +} + +In addition, the Authorization Server MUST store the certificate provided by the Client for use to validate subsequent client authentication attempts. + +5.2 If the request is denied, the Authorization Server returns an error as per Section 3.2.2 of RFC 7591. Denials related to trust validation SHOULD use the “unapproved_software_statement” code. Denials related to invalid signatures SHOULD use the “invalid_software_statement” code. The Authorization Server MAY include an error_description parameter. For example: + +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ +"error": "unapproved_software_statement" +} + +Note: if the Client App receives an error code from the Authorization Server, then the Client App MAY attempt registration again by generating a new software statement specifying a different client certificate. For example, if the Client App has been issued two certificates corresponding to two trust communities, it MAY attempt registration using the second certificate if the registration using the first certificate is denied. The Client App SHOULD NOT attempt registration again with the same certificate if an unapproved_software_statement error is returned. + +6 Modifying or Cancelling previous registrations + +The client URI in the Subject Alternative Name of an X.509 certificate and in the 'iss' field of the software statement uniquely identifies a single application and its operator over time. Thus, a Client App operator MAY request a modification or cancellation of its previous registration with an Authorization Server by submitting another registration request using the same 'iss' value, as discussed below. The Authorization Server SHALL validate such requests in the same manner as a new registration request, following the procedure defined in Section 4 above. Note that registration modification or cancellation requests may not always include the same client certificate as previous registration requests by the same Client App operator, such as in the case of a renewal or re-key of the previous certificate, however the client URI will be the same. + +To modify a previous registration, the Client App operator SHALL submit a valid registration request with a software statement containing the same 'iss' value as an earlier registration but with a different set of claims or claim values, or with a different (possibly empty) set of optional certifications and endorsements. The server SHALL treat this as a request to replace all information from the previous registration request with the information included in the new request. For example, a Client App operator may use this mechanism to update a redirection URI or to add, remove, or update a certification or endorsement. If the registration modification request is accepted, the Authorization Server SHALL return a registration response as per Section 5.1, reflecting the updated registration parameters. The response SHOULD include the same client_id as the previous registration response. If the Authorization Server returns a different client_id, it SHALL cancel the registration associated with the previous client_id, and the client application SHALL use only the new client_id in subsequent transactions with the Authorization Server. + +To cancel a previous registration, the Client App operator SHALL submit a valid registration request with a software statement containing the same 'iss' value as an earlier registration but with an empty grant_types array. The server SHOULD treat this as a request to cancel the previous registration. A client application SHALL interpret a registration response that contains an empty grant_types array as a confirmation that the registration for the client_id listed in the response has been cancelled by the Authorization Server. + +The Authorization Server SHOULD return an HTTP 200 response code (instead of a 201 response code) for successful registration modification and cancellation requests. + +7 References + +Cooper, D., et al. “Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile”, RFC 5280, RFC Editor, May 2008. +Hardt, D., Ed., “The OAuth 2.0 Authorization Framework”, RFC 6749, RFC Editor, October 2012. +Jones, M., et al, “JSON Web Token (JWT)”, RFC 7519, RFC Editor, May 2015. +Richer, J., Ed., “OAuth 2.0 Dynamic Client Registration Protocol”, RFC 7591, RFC Editor, July 2015. + +8 Authors + +Luis C. Maas III, EMR Direct + +9 Acknowledgements + +The author would like to thank the following individuals for their valuable feedback during the development of this document: Julie Maas and Grahame Grieve. + +10 Notices + +Copyright ©2016-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Specification, in full and without modification, solely for the purpose of implementing the technology described in this Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +  + +  + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/docs/specifications/UDAP.org/udap-jwt-client-auth.md b/docs/specifications/UDAP.org/udap-jwt-client-auth.md new file mode 100644 index 000000000..655b44120 --- /dev/null +++ b/docs/specifications/UDAP.org/udap-jwt-client-auth.md @@ -0,0 +1,182 @@ + + + +# **UDAP JWT-based Client Authentication** + +**DRAFT 2022-06-21 with errata** + +Many organizations have established digital identities that can be reused within a trust community to facilitate cross-organizational queries. UDAP JWT-based client authentication leverages existing public key infrastructure and validated identities to help scale such queries. + +UDAP implements JWT-based client authentication as an extension to the OAuth 2.0 authorization framework defined in RFC 6749, based in part on the profiles defined in RFC 7521 and RFC 7523 for assertion-based authentication. To request use of this extension, a Client App constructs and digitally signs a JSON Web Token (JWT) that is used by the Client App as an Authentication Token (AnT), then includes this token as a client assertion to authenticate itself to the Authorization Server’s token endpoint in its request for an access token. This client authentication protocol can be used with any OAuth 2.0 grant mechanism where a Client App authenticates to the Authorization Server token endpoint in order to obtain an access token, including authorization code flow, client credentials flow, or other extension grant flows utilizing the token endpoint. + +Before granting an access token, the Authorization Server uses standard Public Key Infrastructure tools to validate the digital signature on the AnT submitted by the Client App, and to evaluate the trust chain for the Client App’s X.509 certificate. The access token is granted by the Authentication Server only if the AnT is valid (see Section 6) and the Client App’s certificate is trusted. This protocol SHALL only be used only by Client Apps that are able to protect the private key used to sign AnTs, e.g. confidential clients and certain native device apps. + +Note: The HTTP request and response examples presented below are _non-normative_. For readability purposes, line wraps have been added to some HTTP headers and request bodies, some headers have been omitted, and some parameters have not been URL encoded. + +The following steps define the workflow: + +1\. The Client App checks that the Authorization Server supports UDAP by retrieving the Authorization Server’s UDAP metadata from a well-known URL. + +GET /.well-known/udap HTTP/1.1 +Host: resourceholder.example.com + + + +Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"x5c" : ["{_cert1_}", "{_cert2_}", …] +} + +If the Authorization Server returns an error code, then the Authorization Server does not support UDAP JWT-based client authentication and the Client App should abort this workflow. The Authorization Server’s certificates are discovered as part of this metadata request. The Client App MAY examine the certificates to determine whether the Authorization Server is part of a suitable trust community. + +2\. The Client App MUST register with the Authorization Server to use signed JWTs as Authentication Tokens, unless the Client App and Authorization Server support the alternative flow for unregistered client apps described in section 8.1. If the Authorization Server supports UDAP Dynamic Client Registration, the Client App MAY register with the AS using that protocol. If the AS does not support dynamic client registration, it SHOULD supply another method for Client Apps to register their certificates and obtain a client ID. + +3\. The Client App SHOULD perform any steps required by the grant mechanism it is using before making its request to the token endpoint. These steps occur prior to client authentication. + +3.1 Authorization Code Flow + +When using the authorization code flow, the Client App should first direct the end user to the AS’s authorization endpoint in order to receive an authorization code via the Client App’s redirection endpoint, as in this example: + +GET /authorize? +response_type=code& +state=client_random_state& +client_id=clientIDforResourceHolder& +scope= resource_scope1+resource_scope2& +redirect_uri=https://client.example.net/clientredirect HTTP/1.1 +Host: resourceholder.example.com + + + +If the end user authorizes the Client App to the requested resources, the AS will return an authorization code to the Client App by redirecting the user’s browser to the Client App’s redirection endpoint: + +HTTP/1.1 302 Found +Location: https:// client.example.net/clientredirect? +code=authz_code_from_resource_holder& +state=client_random_state + +3.2 Client Credentials Flow + +When using the client credentials flow, no additional steps are required prior to connecting to the token endpoint. + +3.3 For other grant types, perform the steps required by that grant type, if any, prior to connecting to the token endpoint. + +4\. The Client App then prepares an Authentication Token (AnT) for inclusion in the Client App’s request to the AS’s token endpoint. The AnT serves two purposes: it establishes the Client’s control of a private key, and it provides the digital certificate needed to validate the signature and establish trust. The AnT is a signed JWT containing the following claims: + +iss: the client ID issued by the AS to the Client App +sub: the client ID issued by the AS to the Client App +aud: token endpoint URI of the Authorization Server +exp: token expiration time expressed as seconds since epoch (should be short-lived) +iat: issued at time expressed as seconds since epoch +jti: token identifier used to identify token replay + +The JOSE Header for the AnT contains the following key/value pairs: + +alg : "RS256" +x5c : [cert1, cert2, …] +x5u : valid URI (optional) + +The x5c claim contains the Client App’s certificate chain as an array of one or more elements, each containing a base64 encoded representation of the DER encoded X.509 certificate. The AnT is digitally signed and assembled using JWS compact serialization as per RFC 7515. + +5\. The client uses this AnT to authenticate itself as part of its request for an access token from the Authorization Server’s token endpoint. The client indicates that an AnT will be used by including two extension parameters: “client_assertion_type” with a value of “urn:ietf:params:oauth:client-assertion-type:jwt-bearer” and “client_assertion” with a value equal to the JWS compact serialization of the signed AnT constructed in step 4. An additional extension parameter “udap” is also included with a string value of “1” to signal to the AS that version 1 of this protocol is being used. The Client App MUST NOT use HTTP Basic authentication, i.e. an Authorization header MUST NOT appear in the request headers, as no shared client secret is used in this workflow. The client ID MAY be included in the request parameter; if included, it must match the sub value in the AnT. + +5.1 Authorization Code Flow + +Continuing the previous example using the authorization code flow, the Client App submits the authorization code obtained following user authorization: + +POST /token HTTP/1.1 +Host: as.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=authorization_code& +code=authz_code_from_resource_holder& +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer& +client_assertion=eyJh[…remainder of AnT omitted for brevity…]& +udap=1 + +5.2 Client Credentials Flow + +When using the client credentials flow, there is no authorization code for the Client App to submit: + +POST /token HTTP/1.1 +Host: as.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=client_credentials& +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer& +client_assertion=eyJh[…remainder of AnT omitted for brevity…]& +udap=1 + +6\. Authorization Server (AS) validates the Client App’s request. + +6.1 The AS validates the digital signature on the AnT using the public key extracted from cert1 in the x5c parameter of the JOSE header. If the signature cannot be validated, the request is denied. + +6.2 The AS attempts to construct a valid certificate chain from the Client’s certificate (cert1) to an anchor certificate trusted by the AS using conventional X.509 chain building techniques and path validation, including certificate validity and revocation status checking. The Client MUST include its own certificate and MAY include a complete certificate chain in its request. The AS MAY use additional certificates not included by the Client to construct a chain (e.g. from its own certificate cache or discovered via the X.509 AIA mechanism). If a trusted chain cannot be built and validated by the AS, the request is denied. + +6.3 The AS validates the iss, sub, aud, exp, iat, and jti values within the AnT. The iss and sub values MUST correspond to a registered client ID that is permitted to authenticate using an AnT and whose registration is bound to a uniformResourceIdentifier entry in the Subject Alternative Names extension of the Client’s certificate, e.g. via UDAP Dynamic Client Registration. If the request contains a client_id parameter, the client_id value MUST match the iss and sub values. The aud value MUST contain the AS’s token endpoint URI, and the AnT MUST be unexpired. A maximum AnT lifetime of 5 minutes is RECOMMENDED. The AS MAY deny a request if the same AnT (as determined by the jti value) has been used in a previous token request. + +6.4 The AS validates any other parameters in the request as per the requirements of the grant mechanism identified by the grant_type value. If a parameter is invalid or a required parameter is missing, the request is denied. + +6.5 The AS MAY apply additional authorization constraints based on certificate attributes as a matter of local policy. + +7\. Authorization Server responds to request + +7.1 If the request is approved, the Authorization Server returns a token response as per Section 5.1 of RFC 6749. For example: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"access_token": "example_access_token_issued_by_AS", +"token_type": "Bearer", +"expires_in": 3600 +} + +The AS MAY also return a refresh_token in its response. + +7.2 If the request is denied, the AS returns an error as per Section 5.2 of RFC 6749. Denials related to trust validation SHOULD use the “invalid_client” code. Denials related to invalid signatures should use the “invalid_request” code. The AS MAY include an error_description parameter. For example: + +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ +"error": "invalid_client", +"error_description": "The submitted authentication token has expired" +} + +8 Client Authentication without Pre-registration + +8.1 When the identity and privileges of a Client App can be fully determined by an Authorization Server based solely on the attributes listed in the client certificate included in the Authentication Token, the Authorization Server MAY allow an unregistered Client App to use this authentication protocol to obtain an access token using the client_credentials grant flow. In this case, as no client_id exists for the unregistered Client App, the corresponding trust community MUST define the set of certificate attributes that an AS can use to uniquely identify a Client App, and specify a reserved value for the sub claim (e.g. “unregistered”) that can be included in the AnT to signal to the AS that the subject of the AnT is the unregistered Client App identified by the included certificate. This specification does not restrict how the Authorization Server and/or Resource Server communicate to the Client App that this alternate workflow can be used. + +Note that this approach is not suitable for applications utilizing the authorization code flow as there is no mechanism to preregister the Client App’s redirection URI; such unregistered clients SHOULD use the UDAP Dynamic Client Registration Protocol instead. + +9 References + +Campbell, C., et al. “Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants”, RFC 7521, RFC Editor, May 2015. +Cooper, D., et al. “Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile”, RFC 5280, RFC Editor, May 2008. +Hardt, D., Ed., “The OAuth 2.0 Authorization Framework”, RFC 6749, RFC Editor, October 2012. +Jones, M., et al. “JSON Web Signature (JWS)”, RFC 7515, RFC Editor, May 2015. +Jones, M., et al. “JSON Web Token (JWT)”, RFC 7519, RFC Editor, May 2015. +Jones, M., et al. “JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants”, RFC 7523, RFC Editor, May 2015. +Sakimura, N, et al. “OpenID Connect Core 1.0 incorporating errata set 1”, The OpenID Foundation, November 2014. + +10 Authors + +Luis C. Maas III, EMR Direct +Julie W. Maas, EMR Direct + +11 Notices + +Copyright ©2016-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Draft Specification, in full and without modification, solely for the purpose of implementing the technology described in this Draft Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Draft Specifications and Final Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/docs/specifications/UDAP.org/udap-server-metadata.md b/docs/specifications/UDAP.org/udap-server-metadata.md new file mode 100644 index 000000000..1d42ce7a1 --- /dev/null +++ b/docs/specifications/UDAP.org/udap-server-metadata.md @@ -0,0 +1,105 @@ + + + +# **UDAP Server Metadata** + +**Version STU 1** + +The UDAP profiles support the availability of server metadata at a well-known URL, however the specific metadata to require is left to the community based on the profiles and workflows supported. In general, if a community defines all required elements and workflows and does NOT include any optional elements or workflows, the metadata required to support this is limited. However, when optionality is permitted on the Authorization Server side, inclusion of the corresponding metadata is RECOMMENDED. For example, if the profile allows optional support for certain signing algorithms, then it would generally be beneficial to include these algorithms as a required metadata element for Authorization Servers. + +Metadata elements that reflect general OAuth 2.0 concepts SHALL be understood apply to the actor’s UDAP functionality, and SHALL be preferred by other actors over other metadata published by the same actor for other non-UDAP OAuth 2.0 workflows. Additionally, several of the concepts are only relevant to actors in the role of Authorization Server, and some are only relevant when certain profiles are supported. Thus, the metadata requirements for actors in the client and server roles MAY be (and typically will be) different. + +In some cases, some metadata elements may be available via another mechanism supported by the community; such communities are encouraged to include this metadata in the UDAP metadata, as well. + +When a key is included in the metadata with a value listing a set of supported parameters, i.e. the metadata key name ends in “_supported”, other actors MAY assume that, at least for UDAP workflows, the publisher of the metadata does NOT support other parameter values for this key. To communicate that optional functionality represented by a metadata key with an array value is NOT supported, the metadata publisher SHOULD include the key with an empty array value. If a metadata key is omitted, a client application SHOULD NOT make any assumptions regarding the parameter values supported by the metadata publisher, if any. The metadata publisher SHALL NOT publish parameter values supported only for other non-UDAP workflows in its UDAP metadata. Actors MAY ignore unrecognized keys in metadata obtained from other actors. + +This profile allows for the digital signing of endpoint URLs used for OAuth 2.0 transactions, including the authorization, token, and registration endpoints, to allow for Client applications to validate this information before redirecting a user to a server’s authorization endpoint or submitting data to the server’s token or registration endpoint. The primary use case for validation is for a client application to verify the authenticity of these OAuth 2.0 endpoints before initiating a transaction by confirming the metadata is signed by the expected organization. This is analogous to how an Authorization Server validates client-submitted information during UDAP Dynamic Client Registration or JWT-Based Client Authentication transactions. Additionally, for Client apps that utilize the authorization code flow, the inclusion of signed endpoints allows the Client app to display the validated information about the signing organization to the end user for confirmation before redirecting the user to the authorization endpoint, which will generally be more informative and/or granular than the attributes of a server’s TLS certificate. + +1 Supported metadata elements + +The list of supported metadata elements that a community may include are listed below: + +x5c: an array of one or more certificates represented as strings containing the Base64 encoding of the DER representation of the certificate; the first certificate in the array is used by the UDAP actor to sign JWTs and/or to decrypt JWTs; the remaining certificates form a certificate chain, i.e. the subject of the certificate is the issuer of the preceding certificate in the chain. + +udap_versions_supported: an array of strings containing the supported UDAP versions. Currently, the only defined version is “1”. UDAP versions are case-insensitive strings. + +udap_profiles_supported: an array of one or more strings identifying the core UDAP profiles that this actor supports. Values: “udap_dcr” for UDAP Dynamic Client Registration, “udap_authn” for UDAP JWT-Based Client Authentication, “udap_authz” for UDAP Client Authorization, and “udap_to” for UDAP Tiered OAuth. At least one profile SHALL be supported. + +udap_certifications_supported: an array of zero or more strings each containing a URI corresponding to a value recognized by the metadata publisher as an acceptable value in the certification_uris array. The meaning of the value is defined by the publishers of the profiles for the certification or endorsement referenced by each URI. + +udap_certifications_required: an array of one or more strings each containing a URI corresponding to a value recognized by the metadata publisher as a required value in the certification_uris array. The meaning of the value is defined by the publishers of the profiles for the certification or endorsement referenced by each URI. + +udap_authorization_extensions_supported: an array of zero or more recognized key names for authorization extension objects included in the extensions element of an authentication and/or authorization JWT submitted to the token endpoint. The key names are defined by the publishers of the profiles for the corresponding authorization extension objects. + +udap_authorization_extensions_required: an array of one or more recognized key names for authorization extension objects included in the extensions element of an authentication and/or authorization JWT submitted to the token endpoint. The key names are defined by the publishers of the profiles for the corresponding authorization extension objects. + +grant_types_supported: an array of one or more grant types supported by this actor for the UDAP workflows. At least one grant type SHALL be supported. + +scopes_supported: an array of one or more strings supported by this actor for the UDAP workflows. A metadata publisher MAY support a subset of these scopes for different client types or entities. + +authorization_endpoint, token_endpoint, registration_endpoint: a string containing the URI to access the corresponding endpoint. + +signed_endpoints: a signed JWT containing the URI(s) of the authorization_endpoint, token_endpoint, and/or registration_endpoint, as detailed further in section 2. + +token_endpoint_auth_methods_supported: an array on one or more authentication methods supported by the metadata publisher for UDAP workflows. At least one authentication method SHALL be supported. Generally, this array will have a single element: “private_key_jwt”. + +token_endpoint_auth_signing_alg_values_supported: an array of one or more JWA algorithm identifiers supported by the metadata publisher for validation of signed JWTs submitted to the token endpoint for UDAP workflows. At least one signing algorithm SHALL be supported. + +registration_endpoint_jwt_signing_alg_values_supported: an array of one or more JWA algorithm identifiers supported by the metadata publisher for validation of signed software statements, certifications, and endorsements submitted to the registration endpoint. At least one signing algorithm SHALL be supported. It is RECOMMENDED that all Authorization Servers support the “RS256” algorithm. + +Some metadata keys or values only apply in the context of certain supported workflows. Metadata publishers SHOULD NOT include keys or values that are inconsistent with its other published metadata values. For example, if an Authorization Server does not support authorization code flow, the authorization endpoint is not used and the authorization_endpoint key SHOULD NOT be included in the metadata and “authorization_code” SHOULD NOT be included as a supported grant type. + +2 Signed endpoints + +If the server includes an authorization endpoint, token endpoint, or registration endpoint element its metadata, then it SHOULD also include the signed_endpoints element as a signed JWT containing the following claims: + +iss: Server’s unique identifying URI (identifying the holder of the private key, also servers as the base URI for UDAP metadata including lookup of certificates) +sub: same as iss +exp: number, expiration time (may be long-lived, e.g. 1 year) +iat: number, issued at time +jti: string, unique token identifier used to identify token replay +authorization_endpoint: URI of authorization endpoint (if included in unsigned metadata) +token_endpoint: URI of token endpoint (if included in unsigned metadata) +registration_endpoint: URI of registration endpoint (if included in unsigned metadata) + +The JOSE Header for the signed_endpoints JWT contains the following key/value pairs: + +alg : "RS256" +x5c : [cert1, cert2, …] (cert1 is required; remainder of chain is optional) +x5u : valid URI (optional) + +The x5c claim contains the Server’s UDAP certificate chain as an array of one or more elements, each containing a base64 encoded representation of the DER encoded X.509 certificate. The leaf certificate (cert1) contains the public key corresponding to the private signing key used by the Server to digitally sign the signed_endpoints JWT. The JWT is signed and assembled using JWS compact serialization as per RFC 7515. + +3 Client validation of signed endpoints + +If the server metadata contains the signed_endpoints element, then the client application SHALL validate this metadata element as follows: + +3.1 The client app validates the digital signature on the signed_endpoints JWT using the public key extracted from cert1 in the x5c parameter of the JOSE header. If the signature cannot be validated, the request is denied. + +3.2 The Client app attempts to construct a valid certificate chain from the Server’s certificate (cert1) to an anchor certificate trusted by the Client app using conventional X.509 chain building techniques and path validation, including certificate validity and revocation status checking. The Server MAY provide a complete certificate chain in the x5c element. The Client app MAY use additional certificates not included by the Server to construct a chain (e.g. from its own certificate cache or discovered via the X.509 AIA mechanism). Client apps SHOULD support the X.509 AIA mechanism for chain building. If a trusted chain cannot be built and validated by the Client app, the signed_endpoints JWT is rejected. + +3.3 The Client app validates the iss, sub, exp, iat, and jti values in the signed_endpoints JWT. The iss value MUST match a uriName entry in the Subject Alternative Names extension of the Server’s certificate. Typically, this will be the base URL of the resource server providing the metadata. The sub value MUST match the iss value, and the JWT MUST be unexpired. + +3.4 The Client app validates that the endpoint URLs in the unsigned metadata match the values in the signed_endpoints JWT claims. If an endpoint does not match, or an endpoint in the unsigned metadata is not included in the claims, the JWT is rejected. + +3.5 If the Client app rejects the signed_endpoints JWT for any reason, the Client app SHOULD NOT proceed, and, if applicable, SHOULD alert the end user that the server’s endpoint metadata could not be validated. + +4 Authors + +Luis C. Maas III, EMR Direct +Julie Maas, EMR Direct + +5 Notices + +Copyright ©2020-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Specification, in full and without modification, solely for the purpose of implementing the technology described in this Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +  + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/docs/specifications/UDAP.org/udap-tls-client-auth.md b/docs/specifications/UDAP.org/udap-tls-client-auth.md new file mode 100644 index 000000000..2d1349f4d --- /dev/null +++ b/docs/specifications/UDAP.org/udap-tls-client-auth.md @@ -0,0 +1,148 @@ + + + +# **UDAP Mutual TLS Client Authentication** + +**Version STU 1** + +Client authentication using TLS has been deployed broadly in the field and provides an opportunity to leverage existing trust communities to help scale the use of APIs in use cases where authentication of client applications is required, such as in cross-organizational queries. OAuth 2.0 is commonly used as an authorization framework for such use cases. + +UDAP implements TLS client authentication as an extension to the OAuth 2.0 authorization framework defined in RFC 6749. Use of this extension is requested by Client Apps by transmitting a client certificate during the TLS handshake with the token endpoint. This client authentication protocol can be used with any OAuth 2.0 grant mechanism where a Client App authenticates to the Authorization Server’s token endpoint in order to obtain an access token, including authorization code flow, client credentials flow, or other extension grant flows utilizing the token endpoint. + +This protocol is based upon standard mutual TLS session initiation and Public Key Infrastructure tools. Before granting an access token, the Authorization Server validates the digital signature on the certificate used by the Client App during the handshake, and evaluates the trust chain for this certificate. The access token is granted only if the Client App’s certificate is valid and trusted. This protocol MAY be used only by Client Apps that are able to protect the private key corresponding to public key included in the Client App’s X.509 certificate, e.g. confidential clients and certain native device apps. + +The use of mutual TLS authentication by the Client App at the authorization endpoint is not part of this protocol. In fact, the user agent connecting to the authorization endpoint would generally not have access to the Client App’s private key. Additionally, although this document describes the presentation and validation of client certificates, it refers to mutual TLS because client authentication is in addition to the server authentication that is already required by RFC 6749. + +Note: The HTTP request and response examples presented below are _non-normative_. For readability purposes, line wraps have been added to some HTTP headers and request bodies, some headers have been omitted, and some parameters have not been URL encoded. + +The following steps define the workflow: + +1\. The Client App checks that the Authorization Server supports UDAP federation by retrieving the Authorization Server’s UDAP metadata from a well-known URL. + +GET /.well-known/udap HTTP/1.1 +Host: as.example.com + + + +Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +“x5c” : [“{_cert1_}”, “{_cert2_}”, …] +} + +If the Authorization Server returns an error code, then the Authorization Server does not support the UDAP Mutual TLS client authentication protocol and the Client App should abort this workflow. + +2\. The Client App must be registered with the Authorization Server to use TLS client authentication to connect to the token endpoint. If the Authorization Server supports UDAP Dynamic Client Registration, the Client App MAY register with the Authorization Server using that protocol, and request TLS client authentication by including the token_endpoint_auth_method registration parameter with a value of tls_client_auth. If the Authorization Server does not support this dynamic registration protocol, it SHOULD supply another method for Client Apps to register their certificates and obtain a client ID. + +3\. The Client App MUST perform any steps required for the grant type it is using before making its request to the Authorization Server’s token endpoint. These steps occur prior to client authentication. For example, when using the authorization code flow, the Client App would first direct the end user to the Authorization Server’s authorization endpoint in order to receive an authorization code via the Client App’s redirection endpoint, as in this example: + +GET /authorize? +response_type=code& +state=client_random_state& +client_id=clientIDforResourceHolder& +scope= resource_scope1+resource_scope2& +redirect_uri=https://client.example.net/clientredirect HTTP/1.1 +Host: as.example.com + + + +If the end user authorizes the Client App to access the requested resources, the Authorization Server will return an authorization code to the Client App by redirecting the user’s browser to the Client App’s redirection endpoint: + +HTTP/1.1 302 Found +Location: https://client.example.net/clientredirect? +code=authz_code_from_resource_holder& +state=client_random_state + +4\. The Client App connects to the Authorization Server’s token endpoint and completes the TLS handshake with the Authorization Server. The Client App MUST select a suitable client authentication certificate issued by one of the acceptable issuers identified by the Authorization Server during the handshake. + +5\. If the handshake succeeds, the client requests a token from the Authorization Server’s token endpoint. An additional extension parameter “udap” is also included with a string value of “1” to signal to the Authorization Server that version 1 of this protocol is being used. The Client App MUST NOT use HTTP Basic authentication, i.e. an Authorization header MUST NOT appear in the request headers. The client ID MUST be included as a request parameter. Continuing the previous example using the authorization code flow, the Client App also submits the authorization code obtained following user authorization: + +POST /token HTTP/1.1 +Host: as.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=authorization_code& +code=authz_code_from_resource_holder& +client_id=clientIDforResourceHolder & +udap=1 + +If the client is using the client credentials flow, the client will immediately connect to the token endpoint, i.e. the authorization endpoint is not used, as in the following example: + +POST /token HTTP/1.1 +Host: as.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=client_credentials& +client_id=clientIDforResourceHolder & +udap=1 + +6\. Authorization Server validates request + +6.1 The Authorization Server validates that a client certificate has been presented during the TLS handshake between the Client App and the Authorization Server. The Client MUST present its own certificate during the handshake. If a client authentication certificate has not been presented, then this workflow has not been requested by the Client App. + +6.2 The Authorization Server attempts to construct a valid certificate chain from the Client’s certificate presented during the handshake to an anchor certificate trusted by the Authorization Server using conventional X.509 chain building techniques and path validation, including certificate validity and revocation status checking. The Authorization Server MAY use additional certificates not presented by the Client to construct a chain (e.g. from its own certificate cache or discovered via the X.509 AIA mechanism). If a trusted chain cannot be built and validated by the Authorization Server, the request is denied. Note that the Authorization Server MAY perform this trust chain validation during the TLS handshake and MAY terminate the handshake by signaling an appropriate error if trust chain validation fails. + +6.3 The Authorization Server validates that the Subject Distinguished Name and Subject Alternative Names listed in the client’s certificate match the values associated with the client_id presented in the token request. The Subject DN and/or SAN values required to establish a successful match MUST be defined in the corresponding trust community’s certificate profile. If this validation fails, the request is denied. + +6.4 The Authorization Server validates any other parameters in the request as per the requirements of the grant mechanism identified by the grant_type value. If a parameter is invalid or a required parameter is missing, the request is denied. + +7\. Authorization server responds to request + +7.1 If the request is approved, the Authorization Server returns a token response as per Section 5.1 of RFC 6749. For example: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +“access_token”:“example_access_token_issued_by_AS”, +“token_type”:”Bearer”, +“expires_in”:3600 +} + +The Authorization Server MAY also return a refresh_token in its response. + +7.2 If the request is denied, the Authorization Server returns an error as per Section 5.2 of RFC 6749. Denials related to trust validation or client validation SHOULD use the “invalid_client” code. Denials related to invalid signatures should use the “invalid_request” code. The Authorization Server MAY include an error_description parameter. For example: + +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ +“error”: “invalid_client”, +“error_description”:”The submitted authentication token has expired” +} + +8 Alternative workflows using Client TLS authentication directly to Resource Server + +8.1 When the identity and privileges of a Client App can be fully determined by a Resource server based on the attributes in a presented client authentication certificate, a Resource Server may elect to support mutual TLS at its resource endpoints, independent of the Authorization Server and OAuth 2.0 flows, as an alternative to client_credentials grants. In such cases, the Resource Server MUST validate the client authentication certificate as per Sections 6.1 and 6.2 prior to fulfilling the resource request. If the Client App’s certificate is invalid or untrusted, or the certificate attributes cannot be matched to an authorized client application, then the Resource Server SHOULD return a 403 error response with an error parameter value of invalid_client. + +8.2 A Resource Server MAY also require TLS client authentication in conjunction with a conventional Bearer access token in the HTTP Authorization header as a mechanism to constrain the use of an access token to a specific client. In such cases, the Authorization Server MUST communicate the public key or certificate attributes of the authorized Client App to the Resource Server, and the Resource Server, in addition to validating the client authentication certificate as per Section 8.1, MUST validate that th certificate presented by the Client App during the TLS handshake matches those attributes. The Authorization Server may communicate this information to the Resource Server within the access token itself, e.g. when JWTs are used as access tokens, or via its introspection endpoint. If the information does not match, the Resource Server SHOULD return a 403 error response with an error parameter of invalid_grant. + +8.3 This specification does not restrict how the Authorization Server and/or Resource Server communicate to the Client App that the workflows in 8.1 or 8.2 should be used. + +9 References + +Cooper, D., et al. “Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile”, RFC 5280, RFC Editor, May 2008. +Hardt, D., Ed., “The OAuth 2.0 Authorization Framework”, RFC 6749, RFC Editor, October 2012. +Sheffer, Y., et al. “Recommendations for Secure Use of Transport Layer Security (TLS) and Datagram Transport Layer Security (DTLS)”, BCP 195, RFC 7525, RFC Editor, May 2015. + +10 Authors + +Luis C. Maas III, EMR Direct + +11 Notices + +Copyright ©2016-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Specification, in full and without modification, solely for the purpose of implementing the technology described in this Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +  + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/docs/specifications/UDAP.org/udap-user-auth.md b/docs/specifications/UDAP.org/udap-user-auth.md new file mode 100644 index 000000000..084a790a8 --- /dev/null +++ b/docs/specifications/UDAP.org/udap-user-auth.md @@ -0,0 +1,214 @@ + + + +# **UDAP Tiered OAuth for User Authentication** + +**Version STU 1** + +Growing use of open APIs has resulted in demand for richer authentication options, such that Resource Holders can leverage a trusted network of Identity Providers to authenticate users and obtain information about them in order to make an authorization decision. This distributed framework allows the reuse of existing user credentials and improves security by providing user data directly to the Resource Holder rather than passing it through a third party such as a client application. + +UDAP Tiered OAuth for User Authentication implements user authentication as an extension to the OAuth 2.0 authorization and OpenID Connect authentication processes. Use of this extension is requested by Client Apps and Resource Holders by including the “udap” scope in their requests to the upstream authorization endpoint. + +This protocol implements tiered authorization and authentication requests. First the Client App requests authorization (and possibly authentication) from the Resource Holder’s Authorization Server, then the Resource Holder requests user authentication from a trusted upstream Identity Provider (IdP). The Resource Holder evaluates the authentication response and, optionally, additional user details obtained from the IdP before responding to the Client App’s authorization request. Because the Resource Holder obtains the user data directly from the IdP that has authenticated the user instead of from the Client App or other third party, the risk of information leakage or assertion substitution is substantially reduced. + +Note: The HTTP request and response examples presented below are _non-normative_. For readability purposes, line wraps have been added to some HTTP headers and request bodies, some headers have been omitted, and some parameters have not been URL encoded. + +The following steps define the workflow: + +1\. The Client App checks that the Resource Holder supports UDAP by retrieving the Resource Holder’s UDAP metadata from a well-known URL. + +GET /.well-known/udap HTTP/1.1 +Host: resourceholder.example.com + + + +Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"x5c" : ["{_cert1_}", "{_cert2_}", …] +} + +If the Resource Holder’s server returns an error code, then the Resource Holder does not support UDAP Tiered OAuth for User Authentication and the Client App should abort this workflow. + +2\. The Client App directs the user’s browser to the Resource Holder’s authorization endpoint, including as a “hint” the preferred IdP’s URI as the value of the idp parameter of the request. The Client App also includes the scope “udap” in its request to signal to the Resource Holder that UDAP Tiered OAuth for User Authentication is being requested. If this scope is omitted, the behavior of the Resource Holder is unspecified and the UDAP Tiered OAuth for User Authentication workflow defined in this document might not be supported. It is assumed that the Client App has previously registered with the Resource Holder and possesses a client_id issued by the Resource Holder. If not, the Client App MAY first attempt to register with the Resource Holder’s Authorization Server using UDAP Dynamic Client Registration or another registration method. + +The client MAY use either the authorization code or implicit grant flow (if implicit flow is supported by the Resource Holder). The authorization code grant flow is used for this example: + +GET /authorize? +response_type=code& +state=client_random_state& +client_id=clientIDforResourceHolder& +scope=udap+resource_scope1+resource_scope2& +idp=https://idp.example.com/optionalpath& +redirect_uri=https://client.example.net/clientredirect HTTP/1.1 +Host: resourceholder.example.com + + + +3\. Resource Holder interaction with IdP + +3.1 The Resource Holder determines if the IdP is trusted or not trusted by retrieving the IdP’s UDAP metadata from https://idp.example.com/optionalpath/.well-known/udap and evaluating the IdP’s certificate against the Resource Holder’s local trust policies. + +3.2 If the Resource Holder trusts the IdP and does not have a client_id for use with the IdP, then the Resource Holder SHOULD attempt dynamic client registration as a confidential client as per UDAP Dynamic Client Registration. + +3.3 If the Resource Holder does not trust the IdP or cannot obtain a client_id for use with the IdP, then the Resource Holder MAY attempt to authenticate the user with a different IdP. The Resource Holder MAY interact with the user to determine a suitable alternative, such as providing a list of alternative IdP’s to the user. To proceed with a different IdP, the Resource Holder returns to step 3.1 above for the new IdP. Otherwise, the Resource Holder terminates this workflow by redirecting the user’s browser to the Client App’s redirection URI with an error code of “invalid_idp” as per standard OAuth 2.0 error flow, as in this example for a Client using the authorization code flow: + +HTTP/1.1 302 Found +Location: https://client.example.net/clientredirect? +error=invalid_idp& +state=client_random_state + +Note: if the Client App receives such an error code from the Resource Holder, then the Client App MAY attempt to obtain authorization again by specifying a different IdP. + +3.4 If the Resource Holder trusts the IdP and has successfully obtained a client_id from the IdP, then the Resource Holder requests authentication of the user and authorization to access the user’s identity data from the IdP by redirecting the user to the IdP’s authorization endpoint. The Resource Holder’s Authorization Server MAY interact with the user before initiating the redirection, e.g. to inform the user that they will be redirected to the IdP for the purpose of authentication. + +Note that the Resource Holder is acting as a client application when interacting with the IdP. In this tiered relationship, the original Client Application does not interact directly with the IdP. The Resource Holder includes the scopes “openid” and “udap” in its request to signal to the IdP that UDAP Tiered OAuth for User Authentication is being requested. If this scope is omitted, the behavior of the IdP is entirely unspecified and the IdP SHOULD NOT proceed with the UDAP Tiered OAuth for User Authentication workflow. The Resource Holder MUST use the authorization code flow when redirecting the user to the IdP’s authorization endpoint (even if the Client App requested a different grant type when connecting to the Resource Holder’s authorization endpoint): + +HTTP/1.1 302 Found +Location: https://idp.example.com/optionalpath/authorize? +response_type=code& +state=resource_holder_random_state& +client_id=resourceHolderClientIDforIdP& +scope=openid+udap& +nonce=resource_holder_nonce& +redirect_uri=https://resourceholder.example.net/redirect HTTP/1.1 + +The use of the nonce parameter is RECOMMENDED. The Resource Holder MUST generate its own random value for the state parameter and MUST NOT reuse the value provided by the Client App in Step 2. Note: if the Resource Holder interacts with the user prior to the redirection to the IdP, then the Resource Holder MAY alternatively cause the user’s browser to be directed to the IdP’s authorization endpoint as the result of clicking on a hyperlink or other user action that results in the browser generating a new GET request rather than using the 302 redirection method described above. + +4\. IdP interaction with the user + +4.1 The IdP interacts with the user to authenticate the user, if not already logged in, and confirms the user’s authorization to provide the user’s identity or other information to the Resource Holder, if such authorization is needed, following the standard OpenID Connect user authentication process. If successful, the IdP returns an authorization code to the Resource Holder via the redirect URI, e.g.: + +HTTP/1.1 302 Found +Location: https://resourceholder.example.net/redirect? +code=authz_code_from_idp& +state=resource_holder_random_state + +The Resource Holder MUST validate that the value of the state parameter in the query string matches the value generated in Step 3.4. If it does NOT match, the Resource Holder MUST terminate this workflow by redirecting the user’s browser to the Client App’s redirection URI with an error code of “server_error” as per standard OAuth 2.0 error flow. + +4.2 If the IdP cannot authenticate the user, or the user does not authorize the IdP to share the user’s authentication status with the Resource Holder, or another error condition occurs, then the IdP signals an error to the Resource Holder by redirecting the user’s browser to the Resource Holder’s redirection URI with an appropriate error code as per standard OAuth 2.0 flow, e.g.: + +HTTP/1.1 302 Found +Location: https://resourceholder.example.net/redirect? +error=access_denied& +state=resource_holder_random_state + +If the Resource Holder receives such an error response from the IdP, the Resource Holder MUST first validate the value of the state parameter as described in Step 4.1. If the state value is valid, the Resource Holder MUST terminate this workflow by redirecting the user’s browser to the Client App’s redirection URI with an error code of “access_denied” as per standard OAuth 2.0 error flow, as in this example for a Client App using the authorization code flow: + +HTTP/1.1 302 Found +Location: https://client.example.com/redirect? +error=access_denied& +state=client_random_state + +The Client App MUST validate the value of the state parameter returned by the Resource Holder as per RFC 6749. + +4.3 If the Resource Holder receives an authorization code from the IdP and the state value is valid, the Resource Holder then connects to the IDP’s token endpoint on the back-end to retrieve an ID token and access token for use with the IdP’s UserInfo endpoint. The Resource Holder MUST authenticate to the IdP’s token endpoint as detailed in Section 5 of UDAP JWT-based Client Authentication: + +POST /optionalpath/token HTTP/1.1 +Host: idp.example.com +Content-type: application/x-www-form-urlencoded + +grant_type=authorization_code& +code=authz_code_from_idp & +client_assertion_type=urn:ietf:params:oauth:grant-type:jwt-bearer& +client_assertion=eyJh[…remainder of AnT omitted for brevity…]& +udap=1 + +Example Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"access_token": "tokenForResourceHolder", +"token_type": "Bearer", +"expires_in": 300 +"id_token": "{_jwt containing claims listed below_}", +etc. +} + +The IdP MUST NOT issue a refresh token. The access token is intended for immediate use and SHALL have a maximum lifetime of 5 minutes. The ID token includes the IdP’s subject identifier for the end user and information about the user’s most recent authentication event. The Resource Holder MUST validate the ID token as per the standard OpenID Connect validation process. + +The ID Token MUST contain the following claims: + +iss: IdP’s unique identifying URI (matches idp parameter from Step 2) +sub: unique identifier for user in namespace of issuer, i.e. iss + sub is globally unique +aud: client_id of Resource Holder (matches client_id in Resource Holder request in Step 3.4) +exp: expire time (should be short-lived) +iat: issued at time +auth_time: time that user last authenticated (optional) +nonce: when included in Resource Holder’s request, must match nonce value from Step 3.4 +acr: http://udap.org/[ial1-3|loa1-4] +amr: http://udap.org/[aal1-3|loa1-4] + +4.4 If the subject identifier included in the ID token has not been previously mapped to a local user or role, then the Resource Holder MAY request the user’s identity details from the IdP’s UserInfo endpoint using the access token supplied in the response from the IdP’s token endpoint: + +GET /optionalpath/userinfo HTTP/1.1 +Host: idp.example.com +Authorization: Bearer tokenForResourceHolder + + + +Example Response: + +HTTP/1.1 200 OK +Content-Type: application/json + +{ +"iss": {_same as in ID Token_} +"sub": {_same as in ID Token_} +"last_name": {_subject’s last_ name} +...more claims… +} + +Note: The organization operating the upstream IdP MAY make additional resources available to the Resource Holder to support the authentication or refine the scope of authorization of the user, such as when access to resources held by the Resource Holder should be limited based on additional information provided by the upstream IdP’s system. How the IdP communicates the availability of such resources to the Resource Holder is not defined in this specification. + +4.5 The Resource Holder processes the authenticated identity information (for example, by mapping the authenticated user to a local user or role). The Resource Holder MAY interact with the user to resolve the authenticated user’s identity. If the Resource Holder can map the subject to a local user or role, then the Resource Holder MUST also interact with the user to obtain authorization for the Client App to access resources held by the Resource Holder, if such authorization is required. The Resource Holder MAY store this binding for reuse if the same subject identifier is returned in response to a future authentication request. + +4.5.1 If the authentication and authorization steps are successful, the Resource Holder returns an authorization code or access token as appropriate for the response type that was requested by the Client App in Step 2. In this example for a Client App using the authorization code flow, an authorization code is returned: + +HTTP/1.1 302 Found +Location: https://client.example.com/clientredirect? +code=authz_code_from_resource_holder& +state=client_random_state + +The Client App then proceeds with the appropriate step of OAuth 2.0 authorization code flow or implicit grant flow. + +4.5.2 If the Resource Holder cannot map the authenticated identity information provided by the IdP to a local user or role, or the local user does not authorize the Client App to access the resources held by the Resource Holder, then the Resource Holder terminates this workflow by redirecting the user’s browser to the Client App’s redirection URI with an error code of “access_denied” as per standard OAuth 2.0 error flow, as in this example for a Client App using the authorization code flow: + +HTTP/1.1 302 Found +Location: https://client.example.com/clientredirect? +error=access_denied& +state=client_random_state + +The Client App then proceeds with the standard OAuth 2.0 authorization code flow or implicit grant flow, as appropriate. + +5 References + +Cooper, D., et al. “Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile”, RFC 5280, RFC Editor, May 2008. +Grassi, P., et al. “Digital Identity Guidelines, Federation and Assertions”, NIST Special Publication 800-63C, National Institute of Standards and Technology, June 2017. +Hardt, D., Ed., “The OAuth 2.0 Authorization Framework”, RFC 6749, RFC Editor, October 2012. +Sakimura, N, et al. “OpenID Connect Core 1.0 incorporating errata set 1”, The OpenID Foundation, November 2014. + +6 Authors + +Luis C. Maas III, EMR Direct +Julie W. Maas, EMR Direct + +7 Notices + +Copyright ©2016-2025 UDAP.org and the persons identified as the document authors. All rights reserved. + +UDAP.org grants to any interested party a non-exclusive, royalty-free, worldwide right and license to reproduce, publish, distribute and display this Specification, in full and without modification, solely for the purpose of implementing the technology described in this Specification, provided that attribution is made to UDAP.org as the source of the material and that such attribution does not indicate an endorsement by UDAP.org. + +All Specifications, and the information contained therein, are provided on an “AS IS” basis and the authors, the organizations they represent, and UDAP.org make no (and hereby expressly disclaim any) warranties, express, implied, or otherwise, including but not limited to any warranty that the use of the information therein will not infringe any rights or any implied warranties of merchantability or fitness for a particular purpose, and the entire risk as to implementing this specification is assumed by the implementer. Additionally, UDAP.org takes no position regarding the validity or scope of any intellectual property or other rights that might be claimed to pertain to the implementation or use of the technology described in this document or the extent to which any license under such rights might or might not be available, nor does it represent that it has made any independent effort to identify any such rights. + +  + +#### About UDAP + +The Unified Data Access Profiles (UDAP™) published by UDAP.org increase confidence in open API transactions through the use of trusted identities and verified attributes. UDAP use cases support standards-based security, privacy and scalable interoperability through reusable identities, leveraging dynamic client registration, JWT-based client authentication and Tiered OAuth. + diff --git a/examples/CA/.gitignore b/examples/CA/.gitignore new file mode 100644 index 000000000..004749a8f --- /dev/null +++ b/examples/CA/.gitignore @@ -0,0 +1 @@ +publish/ diff --git a/examples/CA/README.md b/examples/CA/README.md new file mode 100644 index 000000000..e4c7783cf --- /dev/null +++ b/examples/CA/README.md @@ -0,0 +1,148 @@ +# Sigil — .NET PKI Management Tool + +Sigil is a modern Certificate Authority (CA) and PKI management platform built with .NET. It provides a web-based UI for creating, importing, and managing X.509 certificate hierarchies with support for local and remote (HashiCorp Vault Transit) key storage. + +Designed as a lightweight alternative to enterprise CA tools like EJBCA, Sigil is suitable for development, testing, and production PKI workflows — with first-class support for [UDAP](https://www.udap.org/) (Unified Data Access Profiles) healthcare security. + +## Features + +- **Certificate Explorer** — Hierarchical tree view of Root CA > Intermediate CA > End-entity > CRL chains with detail panel, ASN.1 viewer, and chain validation +- **Certificate Issuance** — Generate Root CAs, Intermediate CAs, and end-entity certificates from configurable templates (RSA + ECDSA) +- **Certificate Templates** — Preset and custom templates controlling key algorithm, extensions, key usage, EKU, CDP, AIA, SANs, and more +- **Import & Auto-Detection** — Drag-and-drop import of .pfx, .cer, .pem, .crl files with automatic role detection and chain matching +- **Chain Validation** — Offline and online validation with CRL checking, AIA issuer resolution, and CDP CRL fetching +- **Renewal** — Re-key (new key pair) and re-sign (same key, new validity) with in-place entity update to preserve child relationships +- **Vault Transit Signing** — Remote signing via HashiCorp Vault Transit secrets engine (private keys never leave Vault) +- **Hybrid Signing** — Issue end-entity certs with local PFX private keys signed by Vault-backed CAs +- **Dashboard** — Community health, expiring/expired certs, overdue CRLs, and deep-link navigation +- **Multi-Community** — Independent PKI namespaces per community (trust domains, tenants, environments) +- **Download API** — REST endpoints for .cer, .pfx, .pem, and .crl downloads + +See [FEATURES.md](Sigil/docs/FEATURES.md) for the complete feature list and [ROADMAP.md](ROADMAP.md) for planned phases. + +## Architecture + +``` +examples/CA/ +├── Sigil/ # Blazor Server host (Program.cs, DI, config) +├── Sigil.Common/ # Class library (entities, services, ViewModels) +│ ├── Data/Entities/ # EF Core entities (Community, CaCertificate, IssuedCertificate, Crl, ...) +│ ├── Services/ # Issuance, validation, parsing, import, CRL, ASN.1 +│ └── Services/Signing/ # ISigningProvider, LocalSigningProvider, VaultTransitSigningProvider +├── Sigil.UI/ # Razor Class Library (all Blazor components and pages) +├── Sigil.AppHost/ # .NET Aspire orchestrator (Vault + Sigil) +├── Sigil.ServiceDefaults/ # Aspire service defaults (OpenTelemetry, health checks) +└── Sigil.Vault.Hosting/ # Aspire hosting integration for HashiCorp Vault +``` + +**Key design principle:** `Sigil.Common` has zero UI dependencies and can be consumed by CLI tools, APIs, or test harnesses independently. + +**Stack:** .NET 10, Blazor Server (InteractiveServer), FluentUI v4, PostgreSQL, BouncyCastle, Serilog + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [PostgreSQL](https://www.postgresql.org/download/) (15+ recommended) +- [Docker](https://www.docker.com/) (only if using Vault Transit via Aspire) + +## Getting Started + +### 1. Create the PostgreSQL Database + +```sql +CREATE USER sigil WITH PASSWORD 'sigil_pass'; +CREATE DATABASE sigil OWNER sigil; +``` + +### 2. Apply Migrations + +```bash +cd examples/CA/Sigil +dotnet ef database update --project ../Sigil.Common +``` + +Or let Entity Framework apply pending migrations on startup (Sigil calls `Database.MigrateAsync()` at startup). + +### 3. Run Sigil (Standalone) + +```bash +dotnet run --project examples/CA/Sigil +``` + +Sigil will be available at **https://localhost:7200**. + +All certificate signing uses local PFX-based keys by default. + +### 4. Run with Aspire + Vault Transit (Optional) + +To enable remote signing via HashiCorp Vault Transit: + +```bash +dotnet run --project examples/CA/Sigil.AppHost +``` + +This starts: +- **Vault** in dev mode (Docker container) with Transit engine and pre-configured signing keys +- **Sigil** with `Signing.Provider=vault-transit` and Vault connection injected via environment variables + +The Aspire dashboard provides observability for both services. + +## Configuration + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "SigilDb": "Host=localhost;Database=sigil;Username=sigil;Password=sigil_pass;Search Path=sigil" + }, + "Signing": { + "Provider": "local" + } +} +``` + +### Vault Transit (via environment variables or appsettings) + +When running via Aspire AppHost, these are set automatically: + +| Variable | Description | Default | +|----------|-------------|---------| +| `Signing__Provider` | `local` or `vault-transit` | `local` | +| `Vault__Address` | Vault HTTP address | `http://localhost:8200` | +| `Vault__Token` | Vault authentication token | — | +| `Vault__MountPath` | Transit engine mount path | `transit` | + +### Signing Modes + +When Vault Transit is configured, the certificate issuance dialog offers a **Key Storage** selector: + +| Mode | End-Entity Key | Signing Key | PFX Export | +|------|---------------|-------------|------------| +| **Local (PFX)** | Generated locally | Local CA or Vault CA | Yes | +| **Vault Transit** | Generated in Vault | Vault CA | No (key never leaves Vault) | + +This enables a hybrid workflow: Vault-backed CAs can issue end-entity certificates with exportable local private keys. + +## Quick Start Workflow + +1. **Create a Community** — Go to Communities page, add a new PKI namespace +2. **Create a Root CA** — In Certificate Explorer, click "New Root CA", select a Root CA template +3. **Create an Intermediate CA** — Select the Root CA, click "Issue Certificate", pick an Intermediate CA template +4. **Issue End-Entity Certs** — Select the Intermediate CA, issue client or server certificates +5. **Download** — Use the download buttons for .cer, .pfx, or .pem files +6. **Validate** — Click "Revalidate" or "Validate Online" to verify chain integrity + +## Project Dependencies + +| Package | Purpose | +|---------|---------| +| Npgsql.EntityFrameworkCore.PostgreSQL | PostgreSQL provider | +| BouncyCastle.Cryptography 2.6.2 | X.509 operations, CRL generation, chain validation | +| Microsoft.FluentUI.AspNetCore.Components | Blazor UI component library | +| Serilog.AspNetCore | Structured logging | +| Aspire.Hosting (optional) | .NET Aspire orchestration | + +## License + +See [LICENSE](../../LICENSE) in the repository root. diff --git a/examples/CA/ROADMAP.md b/examples/CA/ROADMAP.md new file mode 100644 index 000000000..0a0ed50ce --- /dev/null +++ b/examples/CA/ROADMAP.md @@ -0,0 +1,127 @@ +# Sigil Roadmap — Toward EJBCA Feature Parity + +**Vision**: A modern, .NET-native Certificate Authority and PKI management platform with first-class UDAP/FHIR support. Lightweight enough for dev/test, capable enough for production. + +## Phase 1: Foundation (Complete) +- [x] Project scaffolding (Blazor Server, FluentUI v4, PostgreSQL, Serilog) +- [x] Data model: Community, CaCertificate (self-ref hierarchy), IssuedCertificate, CRL, CertificateRevocation, CertificateTemplate, Job/JobExecution +- [x] Certificate Explorer: tree view + detail panel (extensions, SANs, general info) +- [x] Drag & drop import: .pfx, .cer, .pem, .crl with auto-detection (role, chain matching via AKI/SKI) +- [x] CRL import with signature validation, CRL number tracking, next update +- [x] Bulk import from PKI generator certstores directory +- [x] Communities page (CRUD) +- [x] ASN.1 structure viewer (collapsible tree with OID friendly names, parsed values) +- [x] Dashboard page (expiry warnings, community summary, deep-linking to certs) + +## Phase 2: Certificate Issuance & Templates (Complete) +- [x] Template CRUD page with preset profiles (Root CA, Intermediate CA, UDAP Client, SSL Server) +- [x] Certificate generation engine using .NET CertificateRequest + BouncyCastle +- [x] "Issue Certificate" flow from Explorer: select issuing CA → pick template → fill subject/SANs → generate +- [x] Root CA self-signed generation +- [x] Intermediate CA generation (signed by parent) +- [x] End-entity cert generation with full extension control +- [x] ECDSA support alongside RSA +- [x] Certificate download (.pfx, .cer, .pem) +- [x] Certificate renewal (re-key with new key pair, re-sign with same key) + +## Phase 3: Revocation & CRL Management +- [ ] Revoke certificate action from Explorer (select reason code) +- [ ] CRL generation using BouncyCastle X509V2CrlGenerator +- [ ] Auto-increment CRL number from last known CRL +- [ ] CRL publishing endpoint (HTTP GET returns DER-encoded CRL) +- [ ] CRL viewer in Explorer (show revoked serials, dates, reasons) +- [ ] Delta CRL support + +## Phase 4: Job Scheduler & Monitoring +- [ ] BackgroundJobScheduler (IHostedService + PeriodicTimer) +- [ ] CRL auto-renewal job (regenerate before NextUpdate) +- [ ] Certificate expiry reminder job (configurable window: 30/60/90 days) +- [ ] Jobs page: CRUD, execution history, manual "Run Now" +- [ ] Dashboard integration: expiring certs widget, overdue CRLs, job status +- [ ] Email/webhook notifications for expiry and job failures + +## Phase 5: Certificate Store Providers +- [ ] ICertificateStoreProvider interface finalization +- [ ] File system provider (read/write PFX/PEM on disk) +- [ ] Database provider (current default — PFX bytes in PostgreSQL) +- [ ] PKCS#11 / HSM provider (private keys never leave HSM) +- [ ] Google Cloud KMS provider +- [ ] HashiCorp Vault provider (Transit secrets engine for signing, PKI engine for issuance) +- [ ] Azure Key Vault provider +- [ ] AWS CloudHSM / KMS provider +- [ ] Provider configuration UI (select per-community or per-CA) + +## Phase 6: Protocol Support +- [ ] **EST (RFC 7030)** — Enrollment over Secure Transport (simpleenroll, simplereenroll, cacerts, csrattrs) +- [ ] **SCEP** — Simple Certificate Enrollment Protocol (legacy device support) +- [ ] **CMP (RFC 4210)** — Certificate Management Protocol (cert requests, revocation, key update) +- [ ] **ACME (RFC 8555)** — Automated Certificate Management Environment (Let's Encrypt-style) +- [ ] **REST API** — Full CRUD for certs, CRLs, templates, communities, jobs +- [ ] API authentication (API keys, mTLS, OAuth2) + +## Phase 7: OCSP Responder +- [ ] OCSP responder endpoint (RFC 6960) +- [ ] Delegated OCSP signing certificate support +- [ ] OCSP response caching +- [ ] OCSP stapling support +- [ ] Configurable per-CA (CRL-only vs OCSP vs both) + +## Phase 8: UDAP/FHIR-Specific Features +- [ ] UDAP community trust chain validation (full chain build + CRL check) +- [ ] UDAP metadata generation (.well-known/udap) +- [ ] UDAP-specific certificate templates (SANs with FHIR URIs, UDAP EKUs) +- [ ] TEFCA trust chain profiles +- [ ] Certification & Endorsement JWT generation +- [ ] Community trust anchor distribution +- [ ] Conformance testing integration (validate certs against UDAP spec) + +## Phase 9: Security & Compliance +- [ ] Role-based access control (RBAC) — Admin, Operator, Auditor, RA roles +- [ ] Full audit logging (who did what, when, to which cert) +- [ ] Audit log viewer/export +- [ ] RA (Registration Authority) workflows — request/approve/reject +- [ ] Key escrow / key recovery +- [ ] FIPS 140-3 compliance mode (HSM-only key generation) +- [ ] Certificate transparency (CT) log submission +- [ ] Policy constraints enforcement (name constraints, path length) + +## Phase 10: Operations & Scale +- [ ] High availability (multi-instance with shared DB) +- [ ] Database support: PostgreSQL (primary), SQL Server, MySQL +- [ ] OpenTelemetry integration (traces, metrics) +- [ ] Health check endpoints +- [ ] Docker / Kubernetes deployment manifests +- [ ] Backup/restore for cert stores and DB +- [ ] Import/export in standard formats (PKCS#7 bundles, PEM chains) +- [ ] Bulk operations (renew all expiring, revoke by template, etc.) + +## Phase 11: Advanced CA Features +- [ ] Cross-certification (bridge CA) +- [ ] Certificate hold / unrevoke (reason code 6) +- [ ] Name constraints (permitted/excluded subtrees) +- [ ] Policy mapping between communities +- [x] Certificate archival (soft delete with retention) +- [ ] Subordinate CA provisioning (issue sub-CA certs for external CAs) +- [ ] Multi-algorithm support (RSA, ECDSA, Ed25519, Ed448) + +## Upcoming: Rename "Community" to "Trust Domain" +- [ ] Rename entity `Community` → `TrustDomain` and `CommunityBaseUrl` → `TrustDomainBaseUrl` +- [ ] Rename `CommunityId` foreign keys across all entities (CaCertificate, IssuedCertificate, etc.) +- [ ] EF Core migration to rename tables and columns in PostgreSQL +- [ ] Update all services, ViewModels, and UI references +- [ ] **Rationale**: "Community" conflicts with the UDAP specification's use of the same term. "Trust Domain" is standard PKI terminology for a group of entities sharing a common trust anchor and policy. + +## Architecture Notes +- **Stack**: .NET 10+, Blazor Server (InteractiveServer), FluentUI v4, PostgreSQL, BouncyCastle, Serilog +- **Location**: `examples/CA/` in udap-dotnet repo +- **UI patterns**: Follow TouchStoneNginxProxy and MimeScope (FluentUI v4, code-behind, dark theme) +- **Trust Domains**: Generic PKI hierarchy separator (formerly "Communities"), not tied to UDAP semantics +- **Key principle**: Import-first + generate. Support both bringing in existing PKI and creating new hierarchies. + +## EJBCA Comparison Reference +EJBCA is the benchmark for feature completeness. Key differentiators for Sigil: +1. **UDAP-native** — first-class UDAP/FHIR community support (EJBCA has none) +2. **.NET ecosystem** — integrates naturally with ASP.NET Core, Duende IdentityServer +3. **Modern UI** — FluentUI vs EJBCA's JSF/PrimeFaces +4. **Lightweight** — single app vs EJBCA's JBoss/WildFly stack +5. **Developer-friendly** — designed for PKI management workflows, not just CA operations diff --git a/examples/CA/Sigil.AppHost/.gitignore b/examples/CA/Sigil.AppHost/.gitignore new file mode 100644 index 000000000..01e79881e --- /dev/null +++ b/examples/CA/Sigil.AppHost/.gitignore @@ -0,0 +1 @@ +sigil-gcloud-config/ diff --git a/examples/CA/Sigil.AppHost/Program.cs b/examples/CA/Sigil.AppHost/Program.cs new file mode 100644 index 000000000..e4a5c7029 --- /dev/null +++ b/examples/CA/Sigil.AppHost/Program.cs @@ -0,0 +1,110 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Sigil.Vault.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +// Vault (dev mode) with Transit engine + signing keys +var vault = builder.AddVaultDev("vault") + .WithTransitEngine( + new TransitKeySpec("sigil-rsa-4096", "rsa-4096"), + new TransitKeySpec("sigil-ecdsa-p384", "ecdsa-p384")); + +// Certificate server (static file host for CRLs and certs — always runs as a project) +var certServer = builder.AddProject("certificate-server"); + +// Sigil hosting mode: "project" (default), "docker", or "docker-gcp" +// Set via env var Sigil__HostMode in launch profile. +var hostMode = builder.Configuration["Sigil:HostMode"]?.ToLowerInvariant() ?? "project"; + +// Signing provider: "vault-transit" (default) or "gcp-kms" +// When "gcp-kms", Vault is still started (may be used for other keys) but Sigil signs via GCP Cloud KMS. +var signingProvider = builder.Configuration["Sigil:SigningProvider"]?.ToLowerInvariant() ?? "vault-transit"; + +IResourceBuilder sigil; + +switch (hostMode) +{ + case "docker-gcp": + case "docker": + { + var dockerfile = hostMode == "docker-gcp" ? "Sigil/Dockerfile.gcp" : "Sigil/Dockerfile"; + + var dockerResource = builder.AddDockerfile("sigil", "..", dockerfile) + .WithHttpEndpoint(port: 5200, targetPort: 5200) + .WithHttpsEndpoint(port: 7200, targetPort: 7200) + .WithHttpsCertificateConfiguration(ctx => + { + // Aspire injects its trusted dev cert — Kestrel picks it up via these env vars + if (ctx.Password is null) + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath; + ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath; + } + else + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.PfxPath; + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password; + } + return Task.CompletedTask; + }) + .WithEnvironment("ASPNETCORE_URLS", "https://+:7200;http://+:5200") + .WithReference(vault) + .WithEnvironment("ConnectionStrings__SigilDb", "Host=host.docker.internal;Database=sigil;Username=sigil;Password=sigil_pass;Search Path=sigil") + .WithEnvironment("Vault__Address", vault.Resource.PrimaryEndpoint) + .WithEnvironment("Vault__Token", "root-token") + .WithEnvironment("Signing__Provider", signingProvider); + + if (hostMode == "docker-gcp") + dockerResource.WithVolume("sigil-gcloud-config", "/root/.config/gcloud"); + + // GCP KMS configuration — project/location/keyring passed via env vars + if (signingProvider == "gcp-kms") + { + var gcpProject = builder.Configuration["GcpKms:ProjectId"] ?? ""; + var gcpLocation = builder.Configuration["GcpKms:LocationId"] ?? "us-central1"; + var gcpKeyRing = builder.Configuration["GcpKms:KeyRingId"] ?? "sigil"; + + dockerResource + .WithEnvironment("GcpKms__ProjectId", gcpProject) + .WithEnvironment("GcpKms__LocationId", gcpLocation) + .WithEnvironment("GcpKms__KeyRingId", gcpKeyRing); + } + + sigil = dockerResource; + break; + } + + default: // "project" + var projectResource = builder.AddProject("sigil") + .WithReference(vault) + .WithEnvironment("Vault__Address", vault.Resource.PrimaryEndpoint) + .WithEnvironment("Vault__Token", "root-token") + .WithEnvironment("Signing__Provider", signingProvider); + + // GCP KMS configuration for project mode + if (signingProvider == "gcp-kms") + { + var gcpProject = builder.Configuration["GcpKms:ProjectId"] ?? ""; + var gcpLocation = builder.Configuration["GcpKms:LocationId"] ?? "us-central1"; + var gcpKeyRing = builder.Configuration["GcpKms:KeyRingId"] ?? "sigil"; + + projectResource + .WithEnvironment("GcpKms__ProjectId", gcpProject) + .WithEnvironment("GcpKms__LocationId", gcpLocation) + .WithEnvironment("GcpKms__KeyRingId", gcpKeyRing); + } + + sigil = projectResource; + break; +} + +builder.Build().Run(); diff --git a/examples/CA/Sigil.AppHost/Properties/launchSettings.json b/examples/CA/Sigil.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..4f1ab137e --- /dev/null +++ b/examples/CA/Sigil.AppHost/Properties/launchSettings.json @@ -0,0 +1,68 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22100" + } + }, + "https-docker": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22100", + "Sigil__HostMode": "docker" + } + }, + "https-docker-gcp": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22100", + "Sigil__HostMode": "docker-gcp" + } + }, + "https-docker-gcp-kms": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22100", + "Sigil__HostMode": "docker-gcp", + "Sigil__SigningProvider": "gcp-kms" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20100" + } + } + } +} diff --git a/examples/CA/Sigil.AppHost/Sigil.AppHost.csproj b/examples/CA/Sigil.AppHost/Sigil.AppHost.csproj new file mode 100644 index 000000000..f98d6d644 --- /dev/null +++ b/examples/CA/Sigil.AppHost/Sigil.AppHost.csproj @@ -0,0 +1,27 @@ + + + + + + Exe + net10.0 + enable + enable + false + $(NoWarn);ASPIRECERTIFICATES001 + + + + + + + + + + + + + + + + diff --git a/examples/CA/Sigil.Certificate.Server/Program.cs b/examples/CA/Sigil.Certificate.Server/Program.cs new file mode 100644 index 000000000..0d6dad2a5 --- /dev/null +++ b/examples/CA/Sigil.Certificate.Server/Program.cs @@ -0,0 +1,67 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.FileProviders; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +Log.Information("Starting Sigil Certificate Server"); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Host.UseSerilog((ctx, lc) => lc + .WriteTo.Console( + outputTemplate: + "[{Timestamp:HH:mm:ss} {Level}][{ThreadId:d}] {SourceContext} {Message:lj}{NewLine}{Exception}", + theme: AnsiConsoleTheme.Code) + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) + .Enrich.FromLogContext() + .Enrich.WithThreadId()); + + builder.Services.AddDirectoryBrowser(); + + var app = builder.Build(); + + var provider = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider(); + provider.Mappings[".cer"] = "application/x-x509-ca-cert"; + provider.Mappings[".crl"] = "application/pkix-crl"; + provider.Mappings[".pem"] = "application/x-pem-file"; + + app.UseSerilogRequestLogging(); + + app.UseStaticFiles(new StaticFileOptions + { + ContentTypeProvider = provider + }); + + app.UseDirectoryBrowser(new DirectoryBrowserOptions + { + FileProvider = new PhysicalFileProvider(builder.Environment.WebRootPath) + }); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Unhandled exception"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/examples/CA/Sigil.Certificate.Server/Properties/launchSettings.json b/examples/CA/Sigil.Certificate.Server/Properties/launchSettings.json new file mode 100644 index 000000000..e0ee90dea --- /dev/null +++ b/examples/CA/Sigil.Certificate.Server/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Sigil.Certificate.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://host.docker.internal:5033", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/CA/Sigil.Certificate.Server/Sigil.Certificate.Server.csproj b/examples/CA/Sigil.Certificate.Server/Sigil.Certificate.Server.csproj new file mode 100644 index 000000000..5ba197d96 --- /dev/null +++ b/examples/CA/Sigil.Certificate.Server/Sigil.Certificate.Server.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + false + + + + + + + + diff --git a/examples/CA/Sigil.Certificate.Server/appsettings.json b/examples/CA/Sigil.Certificate.Server/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/examples/CA/Sigil.Certificate.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/CA/Sigil.Certificate.Server/wwwroot/certs/.gitkeep b/examples/CA/Sigil.Certificate.Server/wwwroot/certs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/CA/Sigil.Certificate.Server/wwwroot/crls/.gitkeep b/examples/CA/Sigil.Certificate.Server/wwwroot/crls/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/CA/Sigil.Common/Data/Entities/CaCertificate.cs b/examples/CA/Sigil.Common/Data/Entities/CaCertificate.cs new file mode 100644 index 000000000..3058e5b43 --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/CaCertificate.cs @@ -0,0 +1,62 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Data.Entities; + +public enum CertSecurityLevel : byte +{ + Software = 0, + Fips1403 = 1, + CloudKms = 2 +} + +public class CaCertificate +{ + public int Id { get; set; } + + public int CommunityId { get; set; } + public Community Community { get; set; } = null!; + + /// + /// Null for root CAs; set for intermediates pointing to their issuing CA. + /// + public int? ParentId { get; set; } + public CaCertificate? Parent { get; set; } + + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string X509CertificatePem { get; set; } = string.Empty; + public byte[]? EncryptedPfxBytes { get; set; } + public string? PfxPassword { get; set; } + public string Thumbprint { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public string KeyAlgorithm { get; set; } = "RSA"; + public int KeySize { get; set; } = 4096; + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public string? CrlDistributionPoint { get; set; } + public string? AuthorityInfoAccessUri { get; set; } + public CertSecurityLevel CertSecurityLevel { get; set; } = CertSecurityLevel.Software; + public string? StoreProviderHint { get; set; } + public bool IsRevoked { get; set; } + public DateTime? RevokedAt { get; set; } + public int RevocationReason { get; set; } + public bool Enabled { get; set; } = true; + public bool AutoRenew { get; set; } = true; + public bool IsArchived { get; set; } + public DateTime? ArchivedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public bool IsRootCa => ParentId == null; + + public ICollection Children { get; set; } = new List(); + public ICollection IssuedCertificates { get; set; } = new List(); + public ICollection Crls { get; set; } = new List(); +} diff --git a/examples/CA/Sigil.Common/Data/Entities/CertificateRevocation.cs b/examples/CA/Sigil.Common/Data/Entities/CertificateRevocation.cs new file mode 100644 index 000000000..fb6725439 --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/CertificateRevocation.cs @@ -0,0 +1,33 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Data.Entities; + +public class CertificateRevocation +{ + public int Id { get; set; } + + /// + /// The CRL this revocation entry came from. + /// + public int CrlId { get; set; } + public Crl Crl { get; set; } = null!; + + public string RevokedCertSerialNumber { get; set; } = string.Empty; + public string? RevokedCertThumbprint { get; set; } + public DateTime RevocationDate { get; set; } + + /// + /// CRL reason code (0=Unspecified, 1=KeyCompromise, 2=CACompromise, + /// 3=AffiliationChanged, 4=Superseded, 5=CessationOfOperation, + /// 6=CertificateHold, 9=PrivilegeWithdrawn, 10=AACompromise). + /// + public int RevocationReason { get; set; } +} diff --git a/examples/CA/Sigil.Common/Data/Entities/CertificateTemplate.cs b/examples/CA/Sigil.Common/Data/Entities/CertificateTemplate.cs new file mode 100644 index 000000000..681763302 --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/CertificateTemplate.cs @@ -0,0 +1,80 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; + +namespace Sigil.Common.Data.Entities; + +public enum CertificateType : byte +{ + RootCa = 0, + IntermediateCa = 1, + EndEntityClient = 2, + EndEntityServer = 3 +} + +public class CertificateTemplate +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public CertificateType CertificateType { get; set; } + public string KeyAlgorithm { get; set; } = "RSA"; + public int KeySize { get; set; } = 2048; + public int ValidityDays { get; set; } = 365; + public int KeyUsageFlags { get; set; } = (int)X509KeyUsageFlags.DigitalSignature; + public bool IsKeyUsageCritical { get; set; } = true; + + /// + /// Semicolon-delimited OIDs (e.g., "1.3.6.1.5.5.7.3.2;1.3.6.1.5.5.7.3.1"). + /// + public string? ExtendedKeyUsageOids { get; set; } + + public bool IsBasicConstraintsCa { get; set; } + public bool IsBasicConstraintsCritical { get; set; } = true; + public int? PathLengthConstraint { get; set; } + public bool IsExtendedKeyUsageCritical { get; set; } + + /// + /// "nistP256", "nistP384", or "nistP521". Only used when KeyAlgorithm is "ECDSA". + /// + public string? EcdsaCurve { get; set; } + + public string HashAlgorithm { get; set; } = "SHA256"; + + public string? SubjectTemplate { get; set; } + public bool IncludeCdp { get; set; } + + /// + /// URL template for CRL Distribution Point, e.g. "http://crl.example.com/{CAName}.crl". + /// + public string? CdpUrlTemplate { get; set; } + + public bool IncludeAia { get; set; } + + /// + /// URL template for Authority Information Access, e.g. "http://crl.example.com/{CAName}.cer". + /// + public string? AiaUrlTemplate { get; set; } + + /// + /// Semicolon-delimited SAN type hints for the issuance UI: "URI;DNS;Email;IP". + /// + public string? SubjectAltNameTypes { get; set; } + + /// + /// When true, this is a system-seeded template that cannot be deleted (only cloned). + /// + public bool IsPreset { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection IssuedCertificates { get; set; } = new List(); +} diff --git a/examples/CA/Sigil.Common/Data/Entities/Community.cs b/examples/CA/Sigil.Common/Data/Entities/Community.cs new file mode 100644 index 000000000..40a107dd2 --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/Community.cs @@ -0,0 +1,24 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Data.Entities; + +public class Community +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + + public bool Enabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection BaseUrls { get; set; } = new List(); + public ICollection CaCertificates { get; set; } = new List(); +} diff --git a/examples/CA/Sigil.Common/Data/Entities/CommunityBaseUrl.cs b/examples/CA/Sigil.Common/Data/Entities/CommunityBaseUrl.cs new file mode 100644 index 000000000..a5839f1eb --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/CommunityBaseUrl.cs @@ -0,0 +1,26 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Data.Entities; + +public class CommunityBaseUrl +{ + public int Id { get; set; } + public int CommunityId { get; set; } + public Community Community { get; set; } = null!; + public string Url { get; set; } = string.Empty; + public int SortOrder { get; set; } + + /// + /// Local filesystem path where CRLs and certificates are published for this base URL. + /// The URL path is appended to this directory (e.g., "/crls/MyCA.crl" writes to "{PublishingBasePath}/crls/MyCA.crl"). + /// + public string? PublishingBasePath { get; set; } +} diff --git a/examples/CA/Sigil.Common/Data/Entities/Crl.cs b/examples/CA/Sigil.Common/Data/Entities/Crl.cs new file mode 100644 index 000000000..54662cb69 --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/Crl.cs @@ -0,0 +1,63 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Data.Entities; + +public class Crl +{ + public int Id { get; set; } + + /// + /// The CA that issued this CRL. + /// + public int CaCertificateId { get; set; } + public CaCertificate CaCertificate { get; set; } = null!; + + /// + /// CRL number from the CRL Number extension (2.5.29.20). + /// + public long CrlNumber { get; set; } + + /// + /// When this CRL was issued. + /// + public DateTime ThisUpdate { get; set; } + + /// + /// When the next CRL is expected. Drives job scheduling. + /// + public DateTime NextUpdate { get; set; } + + /// + /// Signature algorithm used to sign the CRL. + /// + public string SignatureAlgorithm { get; set; } = string.Empty; + + /// + /// The raw DER-encoded CRL bytes. + /// + public byte[] RawBytes { get; set; } = []; + + /// + /// Original file name (for display/reference). + /// + public string? FileName { get; set; } + + /// + /// Whether the CRL signature was validated against the issuing CA. + /// + public bool SignatureValid { get; set; } + + public bool IsArchived { get; set; } + public DateTime? ArchivedAt { get; set; } + public DateTime ImportedAt { get; set; } = DateTime.UtcNow; + + public ICollection Revocations { get; set; } = new List(); +} diff --git a/examples/CA/Sigil.Common/Data/Entities/IssuedCertificate.cs b/examples/CA/Sigil.Common/Data/Entities/IssuedCertificate.cs new file mode 100644 index 000000000..57ce9d4ef --- /dev/null +++ b/examples/CA/Sigil.Common/Data/Entities/IssuedCertificate.cs @@ -0,0 +1,45 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Data.Entities; + +public class IssuedCertificate +{ + public int Id { get; set; } + + public int IssuingCaCertificateId { get; set; } + public CaCertificate IssuingCaCertificate { get; set; } = null!; + + public int? TemplateId { get; set; } + public CertificateTemplate? Template { get; set; } + + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string? SubjectAltNames { get; set; } + public string X509CertificatePem { get; set; } = string.Empty; + public byte[]? EncryptedPfxBytes { get; set; } + public string? PfxPassword { get; set; } + public string Thumbprint { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public string KeyAlgorithm { get; set; } = "RSA"; + public int KeySize { get; set; } = 2048; + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public bool IsRevoked { get; set; } + public DateTime? RevokedAt { get; set; } + public int RevocationReason { get; set; } + public CertSecurityLevel CertSecurityLevel { get; set; } = CertSecurityLevel.Software; + public string? StoreProviderHint { get; set; } + public bool Enabled { get; set; } = true; + public bool AutoRenew { get; set; } = true; + public bool IsArchived { get; set; } + public DateTime? ArchivedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/examples/CA/Sigil.Common/Data/SigilDbContext.cs b/examples/CA/Sigil.Common/Data/SigilDbContext.cs new file mode 100644 index 000000000..d760ab13a --- /dev/null +++ b/examples/CA/Sigil.Common/Data/SigilDbContext.cs @@ -0,0 +1,151 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.EntityFrameworkCore; +using Sigil.Common.Data.Entities; + +namespace Sigil.Common.Data; + +public class SigilDbContext : DbContext +{ + public SigilDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Communities => Set(); + public DbSet CommunityBaseUrls => Set(); + public DbSet CaCertificates => Set(); + public DbSet IssuedCertificates => Set(); + public DbSet Crls => Set(); + public DbSet CertificateRevocations => Set(); + public DbSet CertificateTemplates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.HasDefaultSchema("sigil"); + + // Community + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); + entity.HasIndex(e => e.Name).IsUnique(); + }); + + // CommunityBaseUrl + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Url).HasMaxLength(500).IsRequired(); + entity.Property(e => e.PublishingBasePath).HasMaxLength(500); + entity.HasIndex(e => new { e.CommunityId, e.SortOrder }); + + entity.HasOne(e => e.Community) + .WithMany(c => c.BaseUrls) + .HasForeignKey(e => e.CommunityId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // CaCertificate (self-referential for root + intermediate hierarchy) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Thumbprint).IsUnique(); + entity.HasIndex(e => e.CommunityId); + entity.Property(e => e.Subject).HasMaxLength(500).IsRequired(); + entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Thumbprint).HasMaxLength(64).IsRequired(); + entity.Property(e => e.SerialNumber).HasMaxLength(100).IsRequired(); + entity.Property(e => e.KeyAlgorithm).HasMaxLength(20); + entity.Property(e => e.StoreProviderHint).HasMaxLength(200); + entity.Ignore(e => e.IsRootCa); + + entity.HasOne(e => e.Community) + .WithMany(c => c.CaCertificates) + .HasForeignKey(e => e.CommunityId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Parent) + .WithMany(e => e.Children) + .HasForeignKey(e => e.ParentId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // IssuedCertificate + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Thumbprint).IsUnique(); + entity.HasIndex(e => e.IssuingCaCertificateId); + entity.Property(e => e.Subject).HasMaxLength(500).IsRequired(); + entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Thumbprint).HasMaxLength(64).IsRequired(); + entity.Property(e => e.SerialNumber).HasMaxLength(100).IsRequired(); + entity.Property(e => e.KeyAlgorithm).HasMaxLength(20); + entity.Property(e => e.StoreProviderHint).HasMaxLength(200); + + entity.HasOne(e => e.IssuingCaCertificate) + .WithMany(ca => ca.IssuedCertificates) + .HasForeignKey(e => e.IssuingCaCertificateId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Template) + .WithMany(t => t.IssuedCertificates) + .HasForeignKey(e => e.TemplateId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // Crl + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CaCertificateId); + entity.Property(e => e.SignatureAlgorithm).HasMaxLength(50); + entity.Property(e => e.FileName).HasMaxLength(200); + + entity.HasOne(e => e.CaCertificate) + .WithMany(ca => ca.Crls) + .HasForeignKey(e => e.CaCertificateId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // CertificateRevocation + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CrlId); + entity.Property(e => e.RevokedCertSerialNumber).HasMaxLength(100).IsRequired(); + entity.Property(e => e.RevokedCertThumbprint).HasMaxLength(64); + + entity.HasOne(e => e.Crl) + .WithMany(crl => crl.Revocations) + .HasForeignKey(e => e.CrlId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // CertificateTemplate + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); + entity.HasIndex(e => e.Name).IsUnique(); + entity.Property(e => e.KeyAlgorithm).HasMaxLength(20); + entity.Property(e => e.SubjectTemplate).HasMaxLength(500); + entity.Property(e => e.EcdsaCurve).HasMaxLength(20); + entity.Property(e => e.HashAlgorithm).HasMaxLength(10); + entity.Property(e => e.CdpUrlTemplate).HasMaxLength(500); + entity.Property(e => e.AiaUrlTemplate).HasMaxLength(500); + entity.Property(e => e.SubjectAltNameTypes).HasMaxLength(100); + }); + + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260410205446_InitialCreate.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260410205446_InitialCreate.Designer.cs new file mode 100644 index 000000000..6519a5082 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260410205446_InitialCreate.Designer.cs @@ -0,0 +1,554 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260410205446_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetCertificateId") + .HasColumnType("integer"); + + b.Property("TargetEntityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NextRunAt"); + + b.ToTable("Jobs", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobExecutions", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.HasOne("Sigil.Common.Data.Entities.Job", "Job") + .WithMany("Executions") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Navigation("Executions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260410205446_InitialCreate.cs b/examples/CA/Sigil.Common/Migrations/20260410205446_InitialCreate.cs new file mode 100644 index 000000000..f34d20fcc --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260410205446_InitialCreate.cs @@ -0,0 +1,368 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "sigil"); + + migrationBuilder.CreateTable( + name: "CertificateTemplates", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "text", nullable: true), + CertificateType = table.Column(type: "smallint", nullable: false), + KeyAlgorithm = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + KeySize = table.Column(type: "integer", nullable: false), + ValidityDays = table.Column(type: "integer", nullable: false), + KeyUsageFlags = table.Column(type: "integer", nullable: false), + IsKeyUsageCritical = table.Column(type: "boolean", nullable: false), + ExtendedKeyUsageOids = table.Column(type: "text", nullable: true), + IsBasicConstraintsCa = table.Column(type: "boolean", nullable: false), + SubjectTemplate = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + IncludeCdp = table.Column(type: "boolean", nullable: false), + IncludeAia = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CertificateTemplates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Communities", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "text", nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Communities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Jobs", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + JobType = table.Column(type: "smallint", nullable: false), + CronExpression = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + TargetCertificateId = table.Column(type: "integer", nullable: true), + TargetEntityType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + LastRunAt = table.Column(type: "timestamp with time zone", nullable: true), + NextRunAt = table.Column(type: "timestamp with time zone", nullable: true), + Configuration = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Jobs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CaCertificates", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CommunityId = table.Column(type: "integer", nullable: false), + ParentId = table.Column(type: "integer", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Subject = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + X509CertificatePem = table.Column(type: "text", nullable: false), + EncryptedPfxBytes = table.Column(type: "bytea", nullable: true), + PfxPassword = table.Column(type: "text", nullable: true), + Thumbprint = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SerialNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + KeyAlgorithm = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + KeySize = table.Column(type: "integer", nullable: false), + NotBefore = table.Column(type: "timestamp with time zone", nullable: false), + NotAfter = table.Column(type: "timestamp with time zone", nullable: false), + CrlDistributionPoint = table.Column(type: "text", nullable: true), + AuthorityInfoAccessUri = table.Column(type: "text", nullable: true), + CertSecurityLevel = table.Column(type: "smallint", nullable: false), + StoreProviderHint = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CaCertificates", x => x.Id); + table.ForeignKey( + name: "FK_CaCertificates_CaCertificates_ParentId", + column: x => x.ParentId, + principalSchema: "sigil", + principalTable: "CaCertificates", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CaCertificates_Communities_CommunityId", + column: x => x.CommunityId, + principalSchema: "sigil", + principalTable: "Communities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "JobExecutions", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + JobId = table.Column(type: "integer", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Status = table.Column(type: "smallint", nullable: false), + ResultMessage = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_JobExecutions", x => x.Id); + table.ForeignKey( + name: "FK_JobExecutions_Jobs_JobId", + column: x => x.JobId, + principalSchema: "sigil", + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Crls", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CaCertificateId = table.Column(type: "integer", nullable: false), + CrlNumber = table.Column(type: "bigint", nullable: false), + ThisUpdate = table.Column(type: "timestamp with time zone", nullable: false), + NextUpdate = table.Column(type: "timestamp with time zone", nullable: false), + SignatureAlgorithm = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + RawBytes = table.Column(type: "bytea", nullable: false), + FileName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SignatureValid = table.Column(type: "boolean", nullable: false), + ImportedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Crls", x => x.Id); + table.ForeignKey( + name: "FK_Crls_CaCertificates_CaCertificateId", + column: x => x.CaCertificateId, + principalSchema: "sigil", + principalTable: "CaCertificates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "IssuedCertificates", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + IssuingCaCertificateId = table.Column(type: "integer", nullable: false), + TemplateId = table.Column(type: "integer", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Subject = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + SubjectAltNames = table.Column(type: "text", nullable: true), + X509CertificatePem = table.Column(type: "text", nullable: false), + EncryptedPfxBytes = table.Column(type: "bytea", nullable: true), + PfxPassword = table.Column(type: "text", nullable: true), + Thumbprint = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SerialNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + KeyAlgorithm = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + KeySize = table.Column(type: "integer", nullable: false), + NotBefore = table.Column(type: "timestamp with time zone", nullable: false), + NotAfter = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IssuedCertificates", x => x.Id); + table.ForeignKey( + name: "FK_IssuedCertificates_CaCertificates_IssuingCaCertificateId", + column: x => x.IssuingCaCertificateId, + principalSchema: "sigil", + principalTable: "CaCertificates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_IssuedCertificates_CertificateTemplates_TemplateId", + column: x => x.TemplateId, + principalSchema: "sigil", + principalTable: "CertificateTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "CertificateRevocations", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CrlId = table.Column(type: "integer", nullable: false), + RevokedCertSerialNumber = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + RevokedCertThumbprint = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + RevocationDate = table.Column(type: "timestamp with time zone", nullable: false), + RevocationReason = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CertificateRevocations", x => x.Id); + table.ForeignKey( + name: "FK_CertificateRevocations_Crls_CrlId", + column: x => x.CrlId, + principalSchema: "sigil", + principalTable: "Crls", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CaCertificates_CommunityId", + schema: "sigil", + table: "CaCertificates", + column: "CommunityId"); + + migrationBuilder.CreateIndex( + name: "IX_CaCertificates_ParentId", + schema: "sigil", + table: "CaCertificates", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_CaCertificates_Thumbprint", + schema: "sigil", + table: "CaCertificates", + column: "Thumbprint", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CertificateRevocations_CrlId", + schema: "sigil", + table: "CertificateRevocations", + column: "CrlId"); + + migrationBuilder.CreateIndex( + name: "IX_CertificateTemplates_Name", + schema: "sigil", + table: "CertificateTemplates", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Communities_Name", + schema: "sigil", + table: "Communities", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Crls_CaCertificateId", + schema: "sigil", + table: "Crls", + column: "CaCertificateId"); + + migrationBuilder.CreateIndex( + name: "IX_IssuedCertificates_IssuingCaCertificateId", + schema: "sigil", + table: "IssuedCertificates", + column: "IssuingCaCertificateId"); + + migrationBuilder.CreateIndex( + name: "IX_IssuedCertificates_TemplateId", + schema: "sigil", + table: "IssuedCertificates", + column: "TemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_IssuedCertificates_Thumbprint", + schema: "sigil", + table: "IssuedCertificates", + column: "Thumbprint", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_JobExecutions_JobId", + schema: "sigil", + table: "JobExecutions", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_NextRunAt", + schema: "sigil", + table: "Jobs", + column: "NextRunAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CertificateRevocations", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "IssuedCertificates", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "JobExecutions", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "Crls", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "CertificateTemplates", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "Jobs", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "CaCertificates", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "Communities", + schema: "sigil"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260411162947_AddTemplateIssuanceFields.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260411162947_AddTemplateIssuanceFields.Designer.cs new file mode 100644 index 000000000..030ae03cb --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260411162947_AddTemplateIssuanceFields.Designer.cs @@ -0,0 +1,587 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260411162947_AddTemplateIssuanceFields")] + partial class AddTemplateIssuanceFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetCertificateId") + .HasColumnType("integer"); + + b.Property("TargetEntityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NextRunAt"); + + b.ToTable("Jobs", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobExecutions", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.HasOne("Sigil.Common.Data.Entities.Job", "Job") + .WithMany("Executions") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Navigation("Executions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260411162947_AddTemplateIssuanceFields.cs b/examples/CA/Sigil.Common/Migrations/20260411162947_AddTemplateIssuanceFields.cs new file mode 100644 index 000000000..4527c3ad5 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260411162947_AddTemplateIssuanceFields.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddTemplateIssuanceFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260412161855_AddSoftDeleteArchive.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260412161855_AddSoftDeleteArchive.Designer.cs new file mode 100644 index 000000000..a6c27e0bf --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260412161855_AddSoftDeleteArchive.Designer.cs @@ -0,0 +1,605 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260412161855_AddSoftDeleteArchive")] + partial class AddSoftDeleteArchive + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetCertificateId") + .HasColumnType("integer"); + + b.Property("TargetEntityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NextRunAt"); + + b.ToTable("Jobs", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobExecutions", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.HasOne("Sigil.Common.Data.Entities.Job", "Job") + .WithMany("Executions") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Navigation("Executions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260412161855_AddSoftDeleteArchive.cs b/examples/CA/Sigil.Common/Migrations/20260412161855_AddSoftDeleteArchive.cs new file mode 100644 index 000000000..0a4b8da87 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260412161855_AddSoftDeleteArchive.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddSoftDeleteArchive : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedAt", + schema: "sigil", + table: "IssuedCertificates", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsArchived", + schema: "sigil", + table: "IssuedCertificates", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ArchivedAt", + schema: "sigil", + table: "Crls", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsArchived", + schema: "sigil", + table: "Crls", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ArchivedAt", + schema: "sigil", + table: "CaCertificates", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsArchived", + schema: "sigil", + table: "CaCertificates", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedAt", + schema: "sigil", + table: "IssuedCertificates"); + + migrationBuilder.DropColumn( + name: "IsArchived", + schema: "sigil", + table: "IssuedCertificates"); + + migrationBuilder.DropColumn( + name: "ArchivedAt", + schema: "sigil", + table: "Crls"); + + migrationBuilder.DropColumn( + name: "IsArchived", + schema: "sigil", + table: "Crls"); + + migrationBuilder.DropColumn( + name: "ArchivedAt", + schema: "sigil", + table: "CaCertificates"); + + migrationBuilder.DropColumn( + name: "IsArchived", + schema: "sigil", + table: "CaCertificates"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260412194401_WidenStoreProviderHint.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260412194401_WidenStoreProviderHint.Designer.cs new file mode 100644 index 000000000..2e3f92eba --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260412194401_WidenStoreProviderHint.Designer.cs @@ -0,0 +1,605 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260412194401_WidenStoreProviderHint")] + partial class WidenStoreProviderHint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetCertificateId") + .HasColumnType("integer"); + + b.Property("TargetEntityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NextRunAt"); + + b.ToTable("Jobs", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobExecutions", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.HasOne("Sigil.Common.Data.Entities.Job", "Job") + .WithMany("Executions") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Navigation("Executions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260412194401_WidenStoreProviderHint.cs b/examples/CA/Sigil.Common/Migrations/20260412194401_WidenStoreProviderHint.cs new file mode 100644 index 000000000..2c977e988 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260412194401_WidenStoreProviderHint.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class WidenStoreProviderHint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StoreProviderHint", + schema: "sigil", + table: "CaCertificates", + type: "character varying(200)", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StoreProviderHint", + schema: "sigil", + table: "CaCertificates", + type: "character varying(50)", + maxLength: 50, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200, + oldNullable: true); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260413154005_AddIssuedCertStoreProviderHint.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260413154005_AddIssuedCertStoreProviderHint.Designer.cs new file mode 100644 index 000000000..c155552ba --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260413154005_AddIssuedCertStoreProviderHint.Designer.cs @@ -0,0 +1,612 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260413154005_AddIssuedCertStoreProviderHint")] + partial class AddIssuedCertStoreProviderHint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetCertificateId") + .HasColumnType("integer"); + + b.Property("TargetEntityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NextRunAt"); + + b.ToTable("Jobs", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobExecutions", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.JobExecution", b => + { + b.HasOne("Sigil.Common.Data.Entities.Job", "Job") + .WithMany("Executions") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Job", b => + { + b.Navigation("Executions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260413154005_AddIssuedCertStoreProviderHint.cs b/examples/CA/Sigil.Common/Migrations/20260413154005_AddIssuedCertStoreProviderHint.cs new file mode 100644 index 000000000..7ece81903 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260413154005_AddIssuedCertStoreProviderHint.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddIssuedCertStoreProviderHint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CertSecurityLevel", + schema: "sigil", + table: "IssuedCertificates", + type: "smallint", + nullable: false, + defaultValue: (byte)0); + + migrationBuilder.AddColumn( + name: "StoreProviderHint", + schema: "sigil", + table: "IssuedCertificates", + type: "character varying(200)", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CertSecurityLevel", + schema: "sigil", + table: "IssuedCertificates"); + + migrationBuilder.DropColumn( + name: "StoreProviderHint", + schema: "sigil", + table: "IssuedCertificates"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260418064157_ReplaceJobsWithHangfire.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260418064157_ReplaceJobsWithHangfire.Designer.cs new file mode 100644 index 000000000..de916345c --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260418064157_ReplaceJobsWithHangfire.Designer.cs @@ -0,0 +1,520 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260418064157_ReplaceJobsWithHangfire")] + partial class ReplaceJobsWithHangfire + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260418064157_ReplaceJobsWithHangfire.cs b/examples/CA/Sigil.Common/Migrations/20260418064157_ReplaceJobsWithHangfire.cs new file mode 100644 index 000000000..a00ddd48d --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260418064157_ReplaceJobsWithHangfire.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class ReplaceJobsWithHangfire : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobExecutions", + schema: "sigil"); + + migrationBuilder.DropTable( + name: "Jobs", + schema: "sigil"); + + migrationBuilder.AddColumn( + name: "RevocationReason", + schema: "sigil", + table: "IssuedCertificates", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RevocationReason", + schema: "sigil", + table: "IssuedCertificates"); + + migrationBuilder.CreateTable( + name: "Jobs", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Configuration = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CronExpression = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + JobType = table.Column(type: "smallint", nullable: false), + LastRunAt = table.Column(type: "timestamp with time zone", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NextRunAt = table.Column(type: "timestamp with time zone", nullable: true), + TargetCertificateId = table.Column(type: "integer", nullable: true), + TargetEntityType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Jobs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "JobExecutions", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + JobId = table.Column(type: "integer", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + ResultMessage = table.Column(type: "text", nullable: true), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "smallint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_JobExecutions", x => x.Id); + table.ForeignKey( + name: "FK_JobExecutions_Jobs_JobId", + column: x => x.JobId, + principalSchema: "sigil", + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobExecutions_JobId", + schema: "sigil", + table: "JobExecutions", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_NextRunAt", + schema: "sigil", + table: "Jobs", + column: "NextRunAt"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260419122511_AddCaRevocationFields.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260419122511_AddCaRevocationFields.Designer.cs new file mode 100644 index 000000000..0cd0807d7 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260419122511_AddCaRevocationFields.Designer.cs @@ -0,0 +1,529 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260419122511_AddCaRevocationFields")] + partial class AddCaRevocationFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260419122511_AddCaRevocationFields.cs b/examples/CA/Sigil.Common/Migrations/20260419122511_AddCaRevocationFields.cs new file mode 100644 index 000000000..2bdc88424 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260419122511_AddCaRevocationFields.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddCaRevocationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsRevoked", + schema: "sigil", + table: "CaCertificates", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "RevocationReason", + schema: "sigil", + table: "CaCertificates", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "RevokedAt", + schema: "sigil", + table: "CaCertificates", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsRevoked", + schema: "sigil", + table: "CaCertificates"); + + migrationBuilder.DropColumn( + name: "RevocationReason", + schema: "sigil", + table: "CaCertificates"); + + migrationBuilder.DropColumn( + name: "RevokedAt", + schema: "sigil", + table: "CaCertificates"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260419161131_AddCommunityBaseUrl.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260419161131_AddCommunityBaseUrl.Designer.cs new file mode 100644 index 000000000..6470fbd67 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260419161131_AddCommunityBaseUrl.Designer.cs @@ -0,0 +1,532 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260419161131_AddCommunityBaseUrl")] + partial class AddCommunityBaseUrl + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260419161131_AddCommunityBaseUrl.cs b/examples/CA/Sigil.Common/Migrations/20260419161131_AddCommunityBaseUrl.cs new file mode 100644 index 000000000..9307f934f --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260419161131_AddCommunityBaseUrl.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddCommunityBaseUrl : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BaseUrl", + schema: "sigil", + table: "Communities", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BaseUrl", + schema: "sigil", + table: "Communities"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260427163300_AddMultipleCommunityBaseUrls.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260427163300_AddMultipleCommunityBaseUrls.Designer.cs new file mode 100644 index 000000000..547dbb429 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260427163300_AddMultipleCommunityBaseUrls.Designer.cs @@ -0,0 +1,572 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260427163300_AddMultipleCommunityBaseUrls")] + partial class AddMultipleCommunityBaseUrls + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CommunityBaseUrl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("PublishingBasePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId", "SortOrder"); + + b.ToTable("CommunityBaseUrls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CommunityBaseUrl", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("BaseUrls") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Community"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("BaseUrls"); + + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260427163300_AddMultipleCommunityBaseUrls.cs b/examples/CA/Sigil.Common/Migrations/20260427163300_AddMultipleCommunityBaseUrls.cs new file mode 100644 index 000000000..304bb95f7 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260427163300_AddMultipleCommunityBaseUrls.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddMultipleCommunityBaseUrls : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CommunityBaseUrls", + schema: "sigil", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CommunityId = table.Column(type: "integer", nullable: false), + Url = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + PublishingBasePath = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CommunityBaseUrls", x => x.Id); + table.ForeignKey( + name: "FK_CommunityBaseUrls_Communities_CommunityId", + column: x => x.CommunityId, + principalSchema: "sigil", + principalTable: "Communities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CommunityBaseUrls_CommunityId_SortOrder", + schema: "sigil", + table: "CommunityBaseUrls", + columns: new[] { "CommunityId", "SortOrder" }); + + // Migrate existing BaseUrl data to the new table + migrationBuilder.Sql(""" + INSERT INTO sigil."CommunityBaseUrls" ("CommunityId", "Url", "SortOrder") + SELECT "Id", "BaseUrl", 0 + FROM sigil."Communities" + WHERE "BaseUrl" IS NOT NULL AND "BaseUrl" <> '' + """); + + migrationBuilder.DropColumn( + name: "BaseUrl", + schema: "sigil", + table: "Communities"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BaseUrl", + schema: "sigil", + table: "Communities", + type: "text", + nullable: true); + + // Restore first base URL back to the BaseUrl column + migrationBuilder.Sql(""" + UPDATE sigil."Communities" c + SET "BaseUrl" = bu."Url" + FROM sigil."CommunityBaseUrls" bu + WHERE bu."CommunityId" = c."Id" AND bu."SortOrder" = 0 + """); + + migrationBuilder.DropTable( + name: "CommunityBaseUrls", + schema: "sigil"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260429040429_AddAutoRenewFlag.Designer.cs b/examples/CA/Sigil.Common/Migrations/20260429040429_AddAutoRenewFlag.Designer.cs new file mode 100644 index 000000000..79c88b2bd --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260429040429_AddAutoRenewFlag.Designer.cs @@ -0,0 +1,578 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + [Migration("20260429040429_AddAutoRenewFlag")] + partial class AddAutoRenewFlag + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("AutoRenew") + .HasColumnType("boolean"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CommunityBaseUrl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("PublishingBasePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId", "SortOrder"); + + b.ToTable("CommunityBaseUrls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AutoRenew") + .HasColumnType("boolean"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CommunityBaseUrl", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("BaseUrls") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Community"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("BaseUrls"); + + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/20260429040429_AddAutoRenewFlag.cs b/examples/CA/Sigil.Common/Migrations/20260429040429_AddAutoRenewFlag.cs new file mode 100644 index 000000000..5f66f8839 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/20260429040429_AddAutoRenewFlag.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + /// + public partial class AddAutoRenewFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AutoRenew", + schema: "sigil", + table: "IssuedCertificates", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "AutoRenew", + schema: "sigil", + table: "CaCertificates", + type: "boolean", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AutoRenew", + schema: "sigil", + table: "IssuedCertificates"); + + migrationBuilder.DropColumn( + name: "AutoRenew", + schema: "sigil", + table: "CaCertificates"); + } + } +} diff --git a/examples/CA/Sigil.Common/Migrations/SigilDbContextModelSnapshot.cs b/examples/CA/Sigil.Common/Migrations/SigilDbContextModelSnapshot.cs new file mode 100644 index 000000000..0338ec637 --- /dev/null +++ b/examples/CA/Sigil.Common/Migrations/SigilDbContextModelSnapshot.cs @@ -0,0 +1,575 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sigil.Common.Data; + +#nullable disable + +namespace Sigil.Common.Migrations +{ + [DbContext(typeof(SigilDbContext))] + partial class SigilDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sigil") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorityInfoAccessUri") + .HasColumnType("text"); + + b.Property("AutoRenew") + .HasColumnType("boolean"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrlDistributionPoint") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("CaCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CrlId") + .HasColumnType("integer"); + + b.Property("RevocationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedCertSerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedCertThumbprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CrlId"); + + b.ToTable("CertificateRevocations", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiaUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CdpUrlTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CertificateType") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EcdsaCurve") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedKeyUsageOids") + .HasColumnType("text"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IncludeAia") + .HasColumnType("boolean"); + + b.Property("IncludeCdp") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCa") + .HasColumnType("boolean"); + + b.Property("IsBasicConstraintsCritical") + .HasColumnType("boolean"); + + b.Property("IsExtendedKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsKeyUsageCritical") + .HasColumnType("boolean"); + + b.Property("IsPreset") + .HasColumnType("boolean"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("KeyUsageFlags") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PathLengthConstraint") + .HasColumnType("integer"); + + b.Property("SubjectAltNameTypes") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectTemplate") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ValidityDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateTemplates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Communities", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CommunityBaseUrl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommunityId") + .HasColumnType("integer"); + + b.Property("PublishingBasePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CommunityId", "SortOrder"); + + b.ToTable("CommunityBaseUrls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CaCertificateId") + .HasColumnType("integer"); + + b.Property("CrlNumber") + .HasColumnType("bigint"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("NextUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBytes") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignatureAlgorithm") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("ThisUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CaCertificateId"); + + b.ToTable("Crls", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AutoRenew") + .HasColumnType("boolean"); + + b.Property("CertSecurityLevel") + .HasColumnType("smallint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EncryptedPfxBytes") + .HasColumnType("bytea"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("IssuingCaCertificateId") + .HasColumnType("integer"); + + b.Property("KeyAlgorithm") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeySize") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone"); + + b.Property("PfxPassword") + .HasColumnType("text"); + + b.Property("RevocationReason") + .HasColumnType("integer"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StoreProviderHint") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SubjectAltNames") + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X509CertificatePem") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IssuingCaCertificateId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Thumbprint") + .IsUnique(); + + b.ToTable("IssuedCertificates", "sigil"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("CaCertificates") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Community"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateRevocation", b => + { + b.HasOne("Sigil.Common.Data.Entities.Crl", "Crl") + .WithMany("Revocations") + .HasForeignKey("CrlId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Crl"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CommunityBaseUrl", b => + { + b.HasOne("Sigil.Common.Data.Entities.Community", "Community") + .WithMany("BaseUrls") + .HasForeignKey("CommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Community"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "CaCertificate") + .WithMany("Crls") + .HasForeignKey("CaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CaCertificate"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.IssuedCertificate", b => + { + b.HasOne("Sigil.Common.Data.Entities.CaCertificate", "IssuingCaCertificate") + .WithMany("IssuedCertificates") + .HasForeignKey("IssuingCaCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sigil.Common.Data.Entities.CertificateTemplate", "Template") + .WithMany("IssuedCertificates") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("IssuingCaCertificate"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CaCertificate", b => + { + b.Navigation("Children"); + + b.Navigation("Crls"); + + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.CertificateTemplate", b => + { + b.Navigation("IssuedCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Community", b => + { + b.Navigation("BaseUrls"); + + b.Navigation("CaCertificates"); + }); + + modelBuilder.Entity("Sigil.Common.Data.Entities.Crl", b => + { + b.Navigation("Revocations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/CA/Sigil.Common/Services/Asn1ParsingService.cs b/examples/CA/Sigil.Common/Services/Asn1ParsingService.cs new file mode 100644 index 000000000..ab4906eb4 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Asn1ParsingService.cs @@ -0,0 +1,329 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Text; +using Org.BouncyCastle.Asn1; + +namespace Sigil.Common.Services; + +public class Asn1Node +{ + public string TagName { get; set; } = string.Empty; + public string TagClass { get; set; } = string.Empty; + public int TagNumber { get; set; } + public int Offset { get; set; } + public int Length { get; set; } + public string? Value { get; set; } + public string? FriendlyName { get; set; } + public bool IsConstructed { get; set; } + public List Children { get; set; } = new(); +} + +public class Asn1ParsingService +{ + private static readonly Dictionary WellKnownOids = new() + { + // X.500 DN attributes + ["2.5.4.3"] = "commonName", + ["2.5.4.5"] = "serialNumber", + ["2.5.4.6"] = "countryName", + ["2.5.4.7"] = "localityName", + ["2.5.4.8"] = "stateOrProvinceName", + ["2.5.4.10"] = "organizationName", + ["2.5.4.11"] = "organizationalUnitName", + + // X.509 extensions + ["2.5.29.14"] = "subjectKeyIdentifier", + ["2.5.29.15"] = "keyUsage", + ["2.5.29.17"] = "subjectAltName", + ["2.5.29.19"] = "basicConstraints", + ["2.5.29.20"] = "cRLNumber", + ["2.5.29.31"] = "cRLDistributionPoints", + ["2.5.29.32"] = "certificatePolicies", + ["2.5.29.35"] = "authorityKeyIdentifier", + ["2.5.29.37"] = "extKeyUsage", + + // Extended key usage + ["1.3.6.1.5.5.7.3.1"] = "serverAuth", + ["1.3.6.1.5.5.7.3.2"] = "clientAuth", + ["1.3.6.1.5.5.7.3.3"] = "codeSigning", + ["1.3.6.1.5.5.7.3.4"] = "emailProtection", + ["1.3.6.1.5.5.7.3.8"] = "timeStamping", + + // Authority info access + ["1.3.6.1.5.5.7.1.1"] = "authorityInfoAccess", + ["1.3.6.1.5.5.7.48.1"] = "ocsp", + ["1.3.6.1.5.5.7.48.2"] = "caIssuers", + + // Signature algorithms + ["1.2.840.113549.1.1.1"] = "rsaEncryption", + ["1.2.840.113549.1.1.5"] = "sha1WithRSAEncryption", + ["1.2.840.113549.1.1.11"] = "sha256WithRSAEncryption", + ["1.2.840.113549.1.1.12"] = "sha384WithRSAEncryption", + ["1.2.840.113549.1.1.13"] = "sha512WithRSAEncryption", + ["1.2.840.10045.2.1"] = "ecPublicKey", + ["1.2.840.10045.4.3.2"] = "ecdsaWithSHA256", + ["1.2.840.10045.4.3.3"] = "ecdsaWithSHA384", + ["1.2.840.10045.4.3.4"] = "ecdsaWithSHA512", + + // Elliptic curves + ["1.2.840.10045.3.1.7"] = "prime256v1 (P-256)", + ["1.3.132.0.34"] = "secp384r1 (P-384)", + ["1.3.132.0.35"] = "secp521r1 (P-521)", + }; + + public Asn1Node? Parse(byte[] derBytes) + { + try + { + using var stream = new MemoryStream(derBytes); + var asn1Stream = new Asn1InputStream(stream); + var obj = asn1Stream.ReadObject(); + if (obj == null) return null; + + return BuildNode(obj, 0); + } + catch + { + return null; + } + } + + public Asn1Node? ParsePem(string pem) + { + try + { + // Extract DER from PEM + var lines = pem.Split('\n') + .Select(l => l.Trim()) + .Where(l => !l.StartsWith("-----")) + .ToList(); + var base64 = string.Join("", lines); + var derBytes = Convert.FromBase64String(base64); + return Parse(derBytes); + } + catch + { + return null; + } + } + + private Asn1Node BuildNode(Asn1Object obj, int depth) + { + var node = new Asn1Node + { + TagName = GetTagName(obj), + TagClass = GetTagClass(obj), + TagNumber = GetTagNumber(obj), + IsConstructed = obj is Asn1Sequence or Asn1Set or Asn1TaggedObject + }; + + switch (obj) + { + case Asn1Sequence seq: + foreach (var child in seq) + { + if (child is Asn1Object childObj) + node.Children.Add(BuildNode(childObj, depth + 1)); + else if (child is Asn1Encodable encodable) + node.Children.Add(BuildNode(encodable.ToAsn1Object(), depth + 1)); + } + break; + + case Asn1Set set: + foreach (var child in set) + { + if (child is Asn1Object childObj) + node.Children.Add(BuildNode(childObj, depth + 1)); + else if (child is Asn1Encodable encodable) + node.Children.Add(BuildNode(encodable.ToAsn1Object(), depth + 1)); + } + break; + + case Asn1TaggedObject tagged: + node.TagName = $"[{tagged.TagNo}]"; + node.TagNumber = tagged.TagNo; + node.IsConstructed = true; + try + { + var baseObj = tagged.GetBaseObject().ToAsn1Object(); + node.Children.Add(BuildNode(baseObj, depth + 1)); + } + catch + { + node.Value = FormatHex(tagged.GetEncoded()); + node.IsConstructed = false; + } + break; + + case DerObjectIdentifier oid: + node.Value = oid.Id; + if (WellKnownOids.TryGetValue(oid.Id, out var friendly)) + node.FriendlyName = friendly; + break; + + case DerInteger integer: + var bigInt = integer.Value; + node.Value = bigInt.BitLength > 64 + ? FormatHex(bigInt.ToByteArrayUnsigned(), 32) + : bigInt.ToString(); + break; + + case DerBitString bitString: + node.Value = FormatHex(bitString.GetBytes(), 32); + node.Length = bitString.GetBytes().Length; + break; + + case DerOctetString octetString: + // Try to parse as nested ASN.1 + try + { + var nested = new Asn1InputStream(octetString.GetOctets()).ReadObject(); + if (nested is Asn1Sequence or Asn1TaggedObject) + { + node.IsConstructed = true; + node.Children.Add(BuildNode(nested, depth + 1)); + } + else + { + node.Value = FormatHex(octetString.GetOctets(), 32); + } + } + catch + { + node.Value = FormatHex(octetString.GetOctets(), 32); + } + node.Length = octetString.GetOctets().Length; + break; + + case DerUtf8String utf8: + node.Value = utf8.GetString(); + break; + + case DerPrintableString printable: + node.Value = printable.GetString(); + break; + + case DerIA5String ia5: + node.Value = ia5.GetString(); + break; + + case DerVisibleString visible: + node.Value = visible.GetString(); + break; + + case DerBmpString bmp: + node.Value = bmp.GetString(); + break; + + case DerGeneralizedTime genTime: + node.Value = genTime.ToDateTime().ToString("yyyy-MM-dd HH:mm:ss UTC"); + break; + + case DerUtcTime utcTime: + node.Value = utcTime.ToDateTime().ToString("yyyy-MM-dd HH:mm:ss UTC"); + break; + + case DerBoolean boolean: + node.Value = boolean.IsTrue ? "TRUE" : "FALSE"; + break; + + case DerNull: + node.Value = "NULL"; + break; + + case DerEnumerated enumerated: + node.Value = enumerated.Value.ToString(); + break; + + default: + try + { + node.Value = FormatHex(obj.GetEncoded(), 32); + } + catch + { + node.Value = obj.ToString(); + } + break; + } + + return node; + } + + private static string GetTagName(Asn1Object obj) => obj switch + { + Asn1Sequence => "SEQUENCE", + Asn1Set => "SET", + DerObjectIdentifier => "OID", + DerInteger => "INTEGER", + DerBitString => "BIT STRING", + DerOctetString => "OCTET STRING", + DerUtf8String => "UTF8String", + DerPrintableString => "PrintableString", + DerIA5String => "IA5String", + DerVisibleString => "VisibleString", + DerBmpString => "BMPString", + DerGeneralizedTime => "GeneralizedTime", + DerUtcTime => "UTCTime", + DerBoolean => "BOOLEAN", + DerNull => "NULL", + DerEnumerated => "ENUMERATED", + Asn1TaggedObject tagged => $"[{tagged.TagNo}]", + _ => obj.GetType().Name + }; + + private static string GetTagClass(Asn1Object obj) => obj switch + { + Asn1TaggedObject => "CONTEXT", + _ => "UNIVERSAL" + }; + + private static int GetTagNumber(Asn1Object obj) => obj switch + { + Asn1Sequence => 0x10, + Asn1Set => 0x11, + DerObjectIdentifier => 0x06, + DerInteger => 0x02, + DerBitString => 0x03, + DerOctetString => 0x04, + DerUtf8String => 0x0C, + DerPrintableString => 0x13, + DerIA5String => 0x16, + DerUtcTime => 0x17, + DerGeneralizedTime => 0x18, + DerBoolean => 0x01, + DerNull => 0x05, + Asn1TaggedObject tagged => tagged.TagNo, + _ => -1 + }; + + private static string FormatHex(byte[] bytes, int maxBytes = 0) + { + if (bytes.Length == 0) return "(empty)"; + + var hex = Convert.ToHexString(bytes); + + // Insert spaces every 2 chars for readability + var sb = new StringBuilder(); + for (int i = 0; i < hex.Length; i += 2) + { + if (maxBytes > 0 && i / 2 >= maxBytes) + { + sb.Append($"... ({bytes.Length} bytes)"); + break; + } + if (sb.Length > 0) sb.Append(' '); + sb.Append(hex, i, Math.Min(2, hex.Length - i)); + } + + return sb.ToString(); + } +} diff --git a/examples/CA/Sigil.Common/Services/CertificateExtensionHelpers.cs b/examples/CA/Sigil.Common/Services/CertificateExtensionHelpers.cs new file mode 100644 index 000000000..c8a78bdbd --- /dev/null +++ b/examples/CA/Sigil.Common/Services/CertificateExtensionHelpers.cs @@ -0,0 +1,135 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Formats.Asn1; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Sigil.Common.Services; + +/// +/// Static helpers for building X.509 certificate extensions that .NET doesn't natively support. +/// Extracted from Udap.PKI.Generator for reuse by Sigil and CLI tools. +/// +public static class CertificateExtensionHelpers +{ + /// + /// Adds an Authority Key Identifier (AKI) extension (OID 2.5.29.35) + /// derived from the issuing CA's Subject Key Identifier. + /// + public static void AddAuthorityKeyIdentifier( + X509Certificate2 issuerCert, + CertificateRequest request) + { + var issuerSubjectKey = issuerCert.Extensions["2.5.29.14"]?.RawData; + if (issuerSubjectKey == null || issuerSubjectKey.Length < 4) + return; + + var segment = new ArraySegment(issuerSubjectKey, 2, issuerSubjectKey.Length - 2); + var authorityKeyIdentifier = new byte[segment.Count + 4]; + authorityKeyIdentifier[0] = 0x30; // SEQUENCE + authorityKeyIdentifier[1] = 0x16; + authorityKeyIdentifier[2] = 0x80; // [0] implicit KeyIdentifier + authorityKeyIdentifier[3] = 0x14; + segment.CopyTo(authorityKeyIdentifier, 4); + + request.CertificateExtensions.Add( + new X509Extension("2.5.29.35", authorityKeyIdentifier, false)); + } + + /// + /// Adds an Authority Key Identifier extension to a list of extensions (for remote signing path). + /// + public static void AddAuthorityKeyIdentifierToList( + X509Certificate2 issuerCert, + List extensions) + { + var issuerSubjectKey = issuerCert.Extensions["2.5.29.14"]?.RawData; + if (issuerSubjectKey == null || issuerSubjectKey.Length < 4) + return; + + var segment = new ArraySegment(issuerSubjectKey, 2, issuerSubjectKey.Length - 2); + var authorityKeyIdentifier = new byte[segment.Count + 4]; + authorityKeyIdentifier[0] = 0x30; // SEQUENCE + authorityKeyIdentifier[1] = 0x16; + authorityKeyIdentifier[2] = 0x80; // [0] implicit KeyIdentifier + authorityKeyIdentifier[3] = 0x14; + segment.CopyTo(authorityKeyIdentifier, 4); + + extensions.Add(new X509Extension("2.5.29.35", authorityKeyIdentifier, false)); + } + + /// + /// Builds a CRL Distribution Points extension (OID 2.5.29.31) containing one or more distribution points. + /// + public static X509Extension MakeCdp(IReadOnlyList urls) + { + var writer = new AsnWriter(AsnEncodingRules.DER); + using (writer.PushSequence()) // CRLDistributionPoints ::= SEQUENCE OF DistributionPoint + { + foreach (var url in urls) + { + using (writer.PushSequence()) // DistributionPoint ::= SEQUENCE + { + // distributionPoint [0] EXPLICIT + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0, true))) + { + // fullName [0] IMPLICIT GeneralNames + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0, true))) + { + // uniformResourceIdentifier [6] IMPLICIT IA5String + writer.WriteCharacterString( + UniversalTagNumber.IA5String, url, + new Asn1Tag(TagClass.ContextSpecific, 6)); + } + } + } + } + } + + return new X509Extension("2.5.29.31", writer.Encode(), critical: false); + } + + /// + /// Builds a CRL Distribution Points extension for a single URL. + /// + public static X509Extension MakeCdp(string url) => MakeCdp(new[] { url }); + + /// + /// Builds an Authority Information Access (AIA) extension (OID 1.3.6.1.5.5.7.1.1) + /// with one or more CA Issuers access descriptions. + /// + public static X509Extension BuildAiaExtension(IReadOnlyList caIssuerUris, bool critical = false) + { + var writer = new AsnWriter(AsnEncodingRules.DER); + using (writer.PushSequence()) // AuthorityInfoAccessSyntax ::= SEQUENCE OF AccessDescription + { + foreach (var uri in caIssuerUris) + { + using (writer.PushSequence()) // AccessDescription ::= SEQUENCE + { + writer.WriteObjectIdentifier("1.3.6.1.5.5.7.48.2"); // CA Issuers + writer.WriteCharacterString( + UniversalTagNumber.IA5String, + uri.AbsoluteUri, + new Asn1Tag(TagClass.ContextSpecific, 6)); + } + } + } + + return new X509Extension("1.3.6.1.5.5.7.1.1", writer.Encode(), critical); + } + + /// + /// Builds an AIA extension for a single CA Issuer URI. + /// + public static X509Extension BuildAiaExtension(Uri caIssuerUri, bool critical = false) => + BuildAiaExtension(new[] { caIssuerUri }, critical); +} diff --git a/examples/CA/Sigil.Common/Services/CertificateImportService.cs b/examples/CA/Sigil.Common/Services/CertificateImportService.cs new file mode 100644 index 000000000..a4d9137cd --- /dev/null +++ b/examples/CA/Sigil.Common/Services/CertificateImportService.cs @@ -0,0 +1,633 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using Sigil.Common.ViewModels; + +namespace Sigil.Common.Services; + +public class CertificateImportService +{ + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public CertificateImportService( + IDbContextFactory dbFactory, + ILogger logger) + { + _dbFactory = dbFactory; + _logger = logger; + } + + /// + /// Scans a certstores directory and returns a preview of what can be imported. + /// + public List ScanCertStore(string certstoresPath, string pfxPassword = "udap-test") + { + var results = new List(); + + if (!Directory.Exists(certstoresPath)) + { + results.Add(new ImportPreviewViewModel + { + CommunityName = "(error)", + Errors = { $"Directory not found: {certstoresPath}" } + }); + return results; + } + + foreach (var communityDir in Directory.GetDirectories(certstoresPath)) + { + var communityName = Path.GetFileName(communityDir); + var preview = new ImportPreviewViewModel + { + CommunityName = communityName, + DirectoryPath = communityDir + }; + + // Count root CA pfx files at top level + var rootPfxFiles = Directory.GetFiles(communityDir, "*.pfx", SearchOption.TopDirectoryOnly); + preview.RootCaCount = rootPfxFiles.Length; + + // Validate root CA files can be loaded + foreach (var pfxFile in rootPfxFiles) + { + try + { + using var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, pfxPassword, + X509KeyStorageFlags.Exportable); + } + catch (Exception ex) + { + preview.Errors.Add($"Cannot load {Path.GetFileName(pfxFile)}: {ex.Message}"); + } + } + + // Count intermediates + var intermediatesDir = Path.Combine(communityDir, "intermediates"); + if (Directory.Exists(intermediatesDir)) + { + preview.IntermediateCount = Directory.GetFiles(intermediatesDir, "*.pfx").Length; + } + + // Count issued certs + var issuedDir = Path.Combine(communityDir, "issued"); + if (Directory.Exists(issuedDir)) + { + preview.IssuedCertCount = Directory.GetFiles(issuedDir, "*.pfx").Length; + } + + // Count CRL files + var crlDir = Path.Combine(communityDir, "crl"); + if (Directory.Exists(crlDir)) + { + preview.CrlCount = Directory.GetFiles(crlDir, "*.crl").Length; + } + + results.Add(preview); + } + + return results; + } + + /// + /// Imports all certificates from a community directory into the database. + /// + public async Task<(int imported, List errors)> ImportCommunityAsync( + string communityDir, + string pfxPassword = "udap-test", + CancellationToken ct = default) + { + var communityName = Path.GetFileName(communityDir); + var errors = new List(); + int imported = 0; + + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + // Check if community already exists + var existingCommunity = await db.Communities + .FirstOrDefaultAsync(c => c.Name == communityName, ct); + + if (existingCommunity != null) + { + errors.Add($"Community '{communityName}' already exists. Delete it first to re-import."); + return (0, errors); + } + + var community = new Community + { + Name = communityName, + Description = $"Imported from {communityDir}", + Enabled = true + }; + db.Communities.Add(community); + await db.SaveChangesAsync(ct); + + // Import root CAs + var rootCas = new Dictionary(); // SKI -> entity + var rootPfxFiles = Directory.GetFiles(communityDir, "*.pfx", SearchOption.TopDirectoryOnly); + + foreach (var pfxFile in rootPfxFiles) + { + try + { + var (entity, ski) = await ImportCaCertificateAsync(db, pfxFile, pfxPassword, community.Id, null, ct); + if (ski != null) + { + rootCas[ski] = entity; + } + imported++; + _logger.LogInformation("Imported root CA: {Name}", entity.Name); + } + catch (Exception ex) + { + errors.Add($"Error importing {Path.GetFileName(pfxFile)}: {ex.Message}"); + _logger.LogError(ex, "Error importing root CA {File}", pfxFile); + } + } + + // Import intermediates + var intermediatesDir = Path.Combine(communityDir, "intermediates"); + var intermediates = new Dictionary(); // SKI -> entity + + if (Directory.Exists(intermediatesDir)) + { + var intermediatePfxFiles = Directory.GetFiles(intermediatesDir, "*.pfx"); + foreach (var pfxFile in intermediatePfxFiles) + { + try + { + using var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, pfxPassword, + X509KeyStorageFlags.Exportable); + var aki = GetAuthorityKeyIdentifier(cert); + int? parentId = null; + + if (aki != null && rootCas.TryGetValue(aki, out var parentCa)) + { + parentId = parentCa.Id; + } + else if (rootCas.Count == 1) + { + // Fallback: if there's only one root CA, use it + parentId = rootCas.Values.First().Id; + } + + var (entity, ski) = await ImportCaCertificateAsync(db, pfxFile, pfxPassword, community.Id, parentId, ct); + if (ski != null) + { + intermediates[ski] = entity; + } + imported++; + _logger.LogInformation("Imported intermediate: {Name}", entity.Name); + } + catch (Exception ex) + { + errors.Add($"Error importing {Path.GetFileName(pfxFile)}: {ex.Message}"); + _logger.LogError(ex, "Error importing intermediate {File}", pfxFile); + } + } + } + + // Import issued certs + var issuedDir = Path.Combine(communityDir, "issued"); + if (Directory.Exists(issuedDir)) + { + var issuedPfxFiles = Directory.GetFiles(issuedDir, "*.pfx"); + foreach (var pfxFile in issuedPfxFiles) + { + try + { + using var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, pfxPassword, + X509KeyStorageFlags.Exportable); + var aki = GetAuthorityKeyIdentifier(cert); + int? issuingCaId = null; + + // Try to match to intermediate first, then root + if (aki != null) + { + if (intermediates.TryGetValue(aki, out var issuingIntermediate)) + { + issuingCaId = issuingIntermediate.Id; + } + else if (rootCas.TryGetValue(aki, out var issuingRoot)) + { + issuingCaId = issuingRoot.Id; + } + } + + if (issuingCaId == null) + { + // Fallback: if there's one intermediate, use it + if (intermediates.Count == 1) + { + issuingCaId = intermediates.Values.First().Id; + } + else if (rootCas.Count == 1 && intermediates.Count == 0) + { + issuingCaId = rootCas.Values.First().Id; + } + else + { + errors.Add($"Cannot determine issuer for {Path.GetFileName(pfxFile)}"); + continue; + } + } + + var entity = ImportIssuedCertificate(cert, pfxFile, pfxPassword, issuingCaId.Value); + db.IssuedCertificates.Add(entity); + await db.SaveChangesAsync(ct); + imported++; + _logger.LogInformation("Imported issued cert: {Name}", entity.Name); + } + catch (Exception ex) + { + errors.Add($"Error importing {Path.GetFileName(pfxFile)}: {ex.Message}"); + _logger.LogError(ex, "Error importing issued cert {File}", pfxFile); + } + } + } + + // Import CRLs + var crlDir = Path.Combine(communityDir, "crl"); + if (Directory.Exists(crlDir)) + { + var crlFiles = Directory.GetFiles(crlDir, "*.crl"); + foreach (var crlFile in crlFiles) + { + try + { + var crlImported = await ImportCrlAsync(db, crlFile, rootCas, intermediates, ct); + imported += crlImported; + } + catch (Exception ex) + { + errors.Add($"Error importing CRL {Path.GetFileName(crlFile)}: {ex.Message}"); + _logger.LogError(ex, "Error importing CRL {File}", crlFile); + } + } + } + + return (imported, errors); + } + + private async Task<(CaCertificate entity, string? ski)> ImportCaCertificateAsync( + SigilDbContext db, + string pfxFile, + string pfxPassword, + int communityId, + int? parentId, + CancellationToken ct) + { + using var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, pfxPassword, + X509KeyStorageFlags.Exportable); + var pfxBytes = File.ReadAllBytes(pfxFile); + var pem = cert.ExportCertificatePem(); + var ski = GetSubjectKeyIdentifier(cert); + + var (algorithm, keySize) = GetKeyInfo(cert); + + var entity = new CaCertificate + { + CommunityId = communityId, + ParentId = parentId, + Name = Path.GetFileNameWithoutExtension(pfxFile), + Subject = cert.Subject, + X509CertificatePem = pem, + EncryptedPfxBytes = pfxBytes, + PfxPassword = pfxPassword, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = algorithm, + KeySize = keySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CrlDistributionPoint = GetCdpUrl(cert), + AuthorityInfoAccessUri = GetAiaUrl(cert), + CertSecurityLevel = CertSecurityLevel.Software, + Enabled = true + }; + + db.CaCertificates.Add(entity); + await db.SaveChangesAsync(ct); + + return (entity, ski); + } + + private static IssuedCertificate ImportIssuedCertificate( + X509Certificate2 cert, + string pfxFile, + string pfxPassword, + int issuingCaId) + { + var pfxBytes = File.ReadAllBytes(pfxFile); + var pem = cert.ExportCertificatePem(); + var (algorithm, keySize) = GetKeyInfo(cert); + var sans = GetSubjectAltNames(cert); + + return new IssuedCertificate + { + IssuingCaCertificateId = issuingCaId, + Name = Path.GetFileNameWithoutExtension(pfxFile), + Subject = cert.Subject, + SubjectAltNames = sans, + X509CertificatePem = pem, + EncryptedPfxBytes = pfxBytes, + PfxPassword = pfxPassword, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = algorithm, + KeySize = keySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + Enabled = true + }; + } + + private async Task ImportCrlAsync( + SigilDbContext db, + string crlFile, + Dictionary rootCas, + Dictionary intermediates, + CancellationToken ct) + { + var crlBytes = File.ReadAllBytes(crlFile); + var crlParser = new X509CrlParser(); + var crl = crlParser.ReadCrl(crlBytes); + + // Find the issuing CA by matching the CRL issuer to a CA subject + var issuerDn = crl.IssuerDN.ToString(); + CaCertificate? issuingCa = null; + + foreach (var ca in rootCas.Values.Concat(intermediates.Values)) + { + // Compare normalized DN + if (DnMatch(ca.Subject, issuerDn)) + { + issuingCa = ca; + break; + } + } + + if (issuingCa == null) + { + _logger.LogWarning("Cannot find issuing CA for CRL {File} (issuer: {Issuer})", + Path.GetFileName(crlFile), issuerDn); + return 0; + } + + // Extract CRL number + long crlNumber = 0; + var crlNumExt = crl.GetExtensionValue( + Org.BouncyCastle.Asn1.X509.X509Extensions.CrlNumber); + if (crlNumExt != null) + { + var asn1Num = X509ExtensionUtilities.FromExtensionValue(crlNumExt); + crlNumber = DerInteger.GetInstance(asn1Num).LongValueExact; + } + + // Validate CRL signature + bool signatureValid = false; + try + { + var bcCertParser = new X509CertificateParser(); + var bcCaCert = bcCertParser.ReadCertificate( + System.Text.Encoding.UTF8.GetBytes(issuingCa.X509CertificatePem)); + crl.Verify(bcCaCert.GetPublicKey()); + signatureValid = true; + } + catch { } + + // Create CRL entity + var crlEntity = new Crl + { + CaCertificateId = issuingCa.Id, + CrlNumber = crlNumber, + ThisUpdate = crl.ThisUpdate.ToUniversalTime(), + NextUpdate = crl.NextUpdate?.ToUniversalTime() ?? DateTime.MaxValue, + SignatureAlgorithm = crl.SigAlgName, + RawBytes = crlBytes, + FileName = Path.GetFileName(crlFile), + SignatureValid = signatureValid + }; + + db.Crls.Add(crlEntity); + await db.SaveChangesAsync(ct); + + int imported = 0; + var revokedCerts = crl.GetRevokedCertificates(); + + if (revokedCerts != null) + { + foreach (X509CrlEntry entry in revokedCerts) + { + var revocation = new CertificateRevocation + { + CrlId = crlEntity.Id, + RevokedCertSerialNumber = entry.SerialNumber.ToString(16).ToUpperInvariant(), + RevocationDate = entry.RevocationDate.ToUniversalTime(), + RevocationReason = entry.HasExtensions ? GetCrlReason(entry) : 0 + }; + + db.CertificateRevocations.Add(revocation); + imported++; + } + + await db.SaveChangesAsync(ct); + } + + _logger.LogInformation("Imported CRL #{CrlNumber} from {File}: {Count} revocations, signature {Valid}", + crlNumber, Path.GetFileName(crlFile), imported, signatureValid ? "valid" : "INVALID"); + + return imported + 1; // +1 for the CRL entity itself + } + + private static int GetCrlReason(X509CrlEntry entry) + { + try + { + var reasonExt = entry.GetExtensionValue( + Org.BouncyCastle.Asn1.X509.X509Extensions.ReasonCode); + if (reasonExt != null) + { + var asn1 = Org.BouncyCastle.X509.Extension.X509ExtensionUtilities.FromExtensionValue(reasonExt); + var reason = Org.BouncyCastle.Asn1.DerEnumerated.GetInstance(asn1); + return reason.IntValueExact; + } + } + catch + { + // Ignore parse errors + } + + return 0; + } + + private static string? GetSubjectKeyIdentifier(X509Certificate2 cert) + { + var skiExt = cert.Extensions["2.5.29.14"]; + if (skiExt == null) return null; + + var ski = new X509SubjectKeyIdentifierExtension(skiExt, skiExt.Critical); + return ski.SubjectKeyIdentifier; + } + + private static string? GetAuthorityKeyIdentifier(X509Certificate2 cert) + { + var akiExt = cert.Extensions["2.5.29.35"]; + if (akiExt?.RawData == null || akiExt.RawData.Length < 6) return null; + + // Parse AKI: SEQUENCE { [0] OCTET STRING keyIdentifier } + // Skip the outer SEQUENCE tag+length and the [0] context tag+length + try + { + var data = akiExt.RawData; + int offset = 2; // skip SEQUENCE tag + length + + if (data[offset] == 0x80) // [0] implicit tag + { + var len = data[offset + 1]; + var keyId = new byte[len]; + Array.Copy(data, offset + 2, keyId, 0, len); + return Convert.ToHexString(keyId); + } + } + catch + { + // Ignore parse errors + } + + return null; + } + + private static string? GetCdpUrl(X509Certificate2 cert) + { + var cdpExt = cert.Extensions["2.5.29.31"]; + if (cdpExt == null) return null; + + // Simple extraction: find the URI in the raw data + try + { + var rawData = cdpExt.RawData; + return ExtractUriFromAsn1(rawData); + } + catch + { + return null; + } + } + + private static string? GetAiaUrl(X509Certificate2 cert) + { + var aiaExt = cert.Extensions["1.3.6.1.5.5.7.1.1"]; + if (aiaExt == null) return null; + + try + { + var rawData = aiaExt.RawData; + return ExtractUriFromAsn1(rawData); + } + catch + { + return null; + } + } + + private static string? ExtractUriFromAsn1(byte[] data) + { + // Look for the IA5String/GeneralName tag 0x86 (uniformResourceIdentifier) + for (int i = 0; i < data.Length - 2; i++) + { + if (data[i] == 0x86) + { + var len = data[i + 1]; + if (i + 2 + len <= data.Length) + { + return System.Text.Encoding.ASCII.GetString(data, i + 2, len); + } + } + } + + return null; + } + + private static string? GetSubjectAltNames(X509Certificate2 cert) + { + var sanExt = cert.Extensions["2.5.29.17"]; + if (sanExt == null) return null; + + var sans = new List(); + var rawData = sanExt.RawData; + + // Parse: SEQUENCE of GeneralName + // We look for tag 0x86 (URI) and 0x82 (DNS) + for (int i = 0; i < rawData.Length - 2; i++) + { + if (rawData[i] == 0x86 || rawData[i] == 0x82) // URI or DNS + { + var len = rawData[i + 1]; + if (i + 2 + len <= rawData.Length) + { + var value = System.Text.Encoding.ASCII.GetString(rawData, i + 2, len); + var prefix = rawData[i] == 0x86 ? "URI:" : "DNS:"; + sans.Add($"{prefix}{value}"); + i += 1 + len; // skip past this entry + } + } + } + + return sans.Count > 0 ? string.Join("; ", sans) : null; + } + + private static (string algorithm, int keySize) GetKeyInfo(X509Certificate2 cert) + { + var rsa = cert.GetRSAPublicKey(); + if (rsa != null) + { + return ("RSA", rsa.KeySize); + } + + var ecdsa = cert.GetECDsaPublicKey(); + if (ecdsa != null) + { + return ("ECDSA", ecdsa.KeySize); + } + + return ("Unknown", 0); + } + + private static bool DnMatch(string dn1, string dn2) + { + var parts1 = ParseDnParts(dn1); + var parts2 = ParseDnParts(dn2); + return parts1.SetEquals(parts2); + } + + private static HashSet ParseDnParts(string dn) + { + var parts = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var part in dn.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("ST=", StringComparison.OrdinalIgnoreCase)) + trimmed = "S=" + trimmed[3..]; + if (trimmed.StartsWith("s=", StringComparison.OrdinalIgnoreCase)) + trimmed = "S=" + trimmed[2..]; + parts.Add(trimmed); + } + return parts; + } +} diff --git a/examples/CA/Sigil.Common/Services/CertificateIssuanceService.cs b/examples/CA/Sigil.Common/Services/CertificateIssuanceService.cs new file mode 100644 index 000000000..462305049 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/CertificateIssuanceService.cs @@ -0,0 +1,1149 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using Sigil.Common.Services.Signing; +using Sigil.Common.ViewModels; + +namespace Sigil.Common.Services; + +/// +/// Certificate generation engine. Creates Root CAs, Intermediate CAs, and end-entity +/// certificates using .NET's CertificateRequest API with extension helpers extracted +/// from Udap.PKI.Generator. Designed for consumption by UI, CLI, and API hosts. +/// Supports local (PFX) and remote (Vault Transit) signing via ISigningProvider. +/// +public class CertificateIssuanceService +{ + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + private readonly ISigningProvider _signingProvider; + + public CertificateIssuanceService( + IDbContextFactory dbFactory, + ILogger logger, + ISigningProvider? signingProvider = null) + { + _dbFactory = dbFactory; + _logger = logger; + _signingProvider = signingProvider ?? new LocalSigningProvider(); + } + + /// + /// Whether the active signing provider is remote (keys don't leave the provider). + /// + public bool IsRemoteProvider => _signingProvider.ProviderName != "local"; + + /// + /// Issues a certificate based on the given request and template. + /// For Root CAs, IssuingCaCertificateId should be null (self-signed). + /// For Intermediate CAs and end-entity certs, it must reference a CA with a private key. + /// + public async Task IssueCertificateAsync(CertificateIssuanceRequest request) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + + // Load template + var template = await db.CertificateTemplates.FindAsync(request.TemplateId); + if (template == null) + return CertificateIssuanceResult.Failure("Template not found."); + + // Validate community exists + var community = await db.Communities.FindAsync(request.CommunityId); + if (community == null) + return CertificateIssuanceResult.Failure("Community not found."); + + if (string.IsNullOrWhiteSpace(request.SubjectDn)) + return CertificateIssuanceResult.Failure("Subject DN is required."); + + // Determine effective signing mode for this request + bool useRemoteSigning = request.SigningProviderOverride != null + ? request.SigningProviderOverride != "local" + : IsRemoteProvider; + + // PFX password is only required for local signing + if (!useRemoteSigning && string.IsNullOrWhiteSpace(request.PfxPassword)) + return CertificateIssuanceResult.Failure("PFX password is required."); + + // Determine if self-signed (root CA) + bool isSelfSigned = request.IssuingCaCertificateId == null; + + if (isSelfSigned && template.CertificateType != CertificateType.RootCa) + return CertificateIssuanceResult.Failure("Only Root CA templates can be used for self-signed certificates."); + + if (!isSelfSigned && template.CertificateType == CertificateType.RootCa) + return CertificateIssuanceResult.Failure("Root CA template cannot be used with an issuing CA — root CAs are self-signed."); + + // Load issuing CA if not self-signed + X509Certificate2? issuingCert = null; + CaCertificate? issuingCaEntity = null; + SigningKeyReference? issuerKeyRef = null; + + if (!isSelfSigned) + { + issuingCaEntity = await db.CaCertificates.FindAsync(request.IssuingCaCertificateId); + if (issuingCaEntity == null) + return CertificateIssuanceResult.Failure("Issuing CA not found."); + + // Check if the issuing CA uses a remote signing provider + if (issuingCaEntity.StoreProviderHint?.StartsWith("vault-transit:") == true) + { + var vaultKeyName = issuingCaEntity.StoreProviderHint["vault-transit:".Length..]; + issuerKeyRef = new SigningKeyReference( + "vault-transit", vaultKeyName, issuingCaEntity.KeyAlgorithm, issuingCaEntity.KeySize); + + // Load the cert (public only) for issuer DN and extensions + issuingCert = X509Certificate2.CreateFromPem(issuingCaEntity.X509CertificatePem); + } + else if (issuingCaEntity.StoreProviderHint?.StartsWith("gcp-kms:") == true) + { + var kmsKeyId = issuingCaEntity.StoreProviderHint["gcp-kms:".Length..]; + issuerKeyRef = new SigningKeyReference( + "gcp-kms", kmsKeyId, issuingCaEntity.KeyAlgorithm, issuingCaEntity.KeySize); + + issuingCert = X509Certificate2.CreateFromPem(issuingCaEntity.X509CertificatePem); + } + else + { + // Local signing — need the PFX with private key + if (issuingCaEntity.EncryptedPfxBytes == null || string.IsNullOrEmpty(issuingCaEntity.PfxPassword)) + return CertificateIssuanceResult.Failure("Issuing CA does not have a private key. Import the PFX first."); + + try + { + issuingCert = X509CertificateLoader.LoadPkcs12( + issuingCaEntity.EncryptedPfxBytes, + issuingCaEntity.PfxPassword, + X509KeyStorageFlags.Exportable); + } + catch (Exception ex) + { + return CertificateIssuanceResult.Failure($"Failed to load issuing CA private key: {ex.Message}"); + } + } + } + + // Route to appropriate signing path: + // 1. Full remote: new cert key AND signing both in remote provider + // 2. Hybrid: new cert key is LOCAL (PFX), but issuer signs via remote provider (Vault Transit) + // 3. Full local: both key and signing are local (existing code below) + if (useRemoteSigning) + { + return await IssueCertificateRemoteAsync( + db, template, request, isSelfSigned, issuingCert, issuingCaEntity, issuerKeyRef); + } + + if (issuerKeyRef != null) + { + // Hybrid: local key + remote signing (e.g., end-entity with PFX, signed by Vault CA) + return await IssueCertificateHybridAsync( + db, template, request, issuingCert!, issuingCaEntity!, issuerKeyRef); + } + + try + { + // Generate key pair + using var keyHolder = GenerateKeyPair(template); + + // Build CertificateRequest + var certRequest = CreateCertificateRequest(template, request.SubjectDn, keyHolder); + + // Add extensions + AddExtensions(certRequest, template, request, issuingCert); + + // Determine validity + var notBefore = request.NotBefore ?? DateTimeOffset.UtcNow; + var notAfter = request.NotAfter ?? notBefore.AddDays(template.ValidityDays); + + // Clamp notAfter to issuing CA's expiry — .NET won't issue past the issuer's NotAfter + if (issuingCert != null && notAfter > issuingCert.NotAfter) + { + notAfter = new DateTimeOffset(issuingCert.NotAfter.ToUniversalTime(), TimeSpan.Zero); + } + + // Sign and create certificate + X509Certificate2 cert; + if (isSelfSigned) + { + cert = certRequest.CreateSelfSigned(notBefore, notAfter); + } + else + { + var serialBytes = RandomNumberGenerator.GetBytes(16); + using var signedCert = certRequest.Create( + issuingCert!.SubjectName, + CreateSignatureGenerator(issuingCert!), + notBefore, + notAfter, + serialBytes); + + // Attach private key + cert = AttachPrivateKey(signedCert, keyHolder); + } + + using (cert) + { + // Verify the new cert was actually signed by the issuing CA + if (!isSelfSigned) + { + var issuerError = VerifyIssuedBy(cert, issuingCert!); + if (issuerError != null) + return CertificateIssuanceResult.Failure(issuerError); + } + + // Export + var pfxBytes = cert.Export(X509ContentType.Pkcs12, request.PfxPassword); + var pem = cert.ExportCertificatePem(); + + var certName = string.IsNullOrWhiteSpace(request.CertificateName) + ? ExtractCnFromDn(request.SubjectDn) ?? request.SubjectDn + : request.CertificateName; + + // Determine key size for storage + int keySize = GetKeySize(cert); + + // Store in database + bool isCaType = template.CertificateType is CertificateType.RootCa + or CertificateType.IntermediateCa; + + if (isCaType) + { + var caEntity = new CaCertificate + { + CommunityId = request.CommunityId, + ParentId = isSelfSigned ? null : request.IssuingCaCertificateId, + Name = certName, + Subject = cert.Subject, + X509CertificatePem = pem, + EncryptedPfxBytes = pfxBytes, + PfxPassword = request.PfxPassword, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = template.KeyAlgorithm, + KeySize = keySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CrlDistributionPoint = request.CdpUrls.Count > 0 ? string.Join(";", request.CdpUrls) : null, + AuthorityInfoAccessUri = request.AiaUrls.Count > 0 ? string.Join(";", request.AiaUrls) : null, + }; + + db.CaCertificates.Add(caEntity); + await db.SaveChangesAsync(); + + await PublishCaCertAndCrlAsync(request.CommunityId, certName, cert.RawData); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = caEntity.Id, + EntityType = "CaCertificate", + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber + }; + } + else + { + var sanString = request.SubjectAltNames.Count > 0 + ? string.Join(";", request.SubjectAltNames.Select(s => $"{s.Type}:{s.Value}")) + : null; + + var issuedEntity = new IssuedCertificate + { + IssuingCaCertificateId = request.IssuingCaCertificateId!.Value, + TemplateId = request.TemplateId, + Name = certName, + Subject = cert.Subject, + SubjectAltNames = sanString, + X509CertificatePem = pem, + EncryptedPfxBytes = pfxBytes, + PfxPassword = request.PfxPassword, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = template.KeyAlgorithm, + KeySize = keySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + }; + + db.IssuedCertificates.Add(issuedEntity); + await db.SaveChangesAsync(); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = issuedEntity.Id, + EntityType = "IssuedCertificate", + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber + }; + } + } + } + catch (Exception ex) + { + return CertificateIssuanceResult.Failure($"Certificate generation failed: {ex.Message}"); + } + finally + { + issuingCert?.Dispose(); + } + } + + /// + /// Re-signs an existing certificate with the same key pair but new serial and validity. + /// The SKI remains the same so downstream certificate chains continue to validate. + /// Creates a new certificate entity; the old one is preserved. + /// + public async Task ResignCertificateAsync(CertificateResignRequest request) + { + if (string.IsNullOrWhiteSpace(request.PfxPassword)) + return CertificateIssuanceResult.Failure("PFX password is required."); + + await using var db = await _dbFactory.CreateDbContextAsync(); + + // Currently only CA certificates can be re-signed (they have hierarchy) + if (request.EntityType != "CaCertificate") + return CertificateIssuanceResult.Failure("Only CA certificates can be re-signed."); + + var caEntity = await db.CaCertificates.FindAsync(request.ExistingCertificateId); + if (caEntity == null) + return CertificateIssuanceResult.Failure("Certificate not found."); + + if (caEntity.EncryptedPfxBytes == null || string.IsNullOrEmpty(caEntity.PfxPassword)) + return CertificateIssuanceResult.Failure("Certificate does not have a private key."); + + // Load existing cert with private key + X509Certificate2 existingCert; + try + { + existingCert = X509CertificateLoader.LoadPkcs12( + caEntity.EncryptedPfxBytes, + caEntity.PfxPassword, + X509KeyStorageFlags.Exportable); + } + catch (Exception ex) + { + return CertificateIssuanceResult.Failure($"Failed to load certificate: {ex.Message}"); + } + + // Load parent CA for signing (if not self-signed root) + X509Certificate2? parentCert = null; + bool isSelfSigned = caEntity.ParentId == null; + + if (!isSelfSigned) + { + var parentEntity = await db.CaCertificates.FindAsync(caEntity.ParentId); + if (parentEntity?.EncryptedPfxBytes == null || string.IsNullOrEmpty(parentEntity.PfxPassword)) + { + existingCert.Dispose(); + return CertificateIssuanceResult.Failure("Parent CA does not have a private key."); + } + + try + { + parentCert = X509CertificateLoader.LoadPkcs12( + parentEntity.EncryptedPfxBytes, + parentEntity.PfxPassword, + X509KeyStorageFlags.Exportable); + } + catch (Exception ex) + { + existingCert.Dispose(); + return CertificateIssuanceResult.Failure($"Failed to load parent CA: {ex.Message}"); + } + } + + try + { + // Build CertificateRequest using the SAME key from the existing cert + var rsaKey = existingCert.GetRSAPrivateKey(); + var ecdsaKey = existingCert.GetECDsaPrivateKey(); + + CertificateRequest certRequest; + if (ecdsaKey != null) + { + certRequest = new CertificateRequest( + existingCert.SubjectName, + ecdsaKey, + HashAlgorithmName.SHA256); + } + else if (rsaKey != null) + { + certRequest = new CertificateRequest( + existingCert.SubjectName, + rsaKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + else + { + return CertificateIssuanceResult.Failure("Unsupported key algorithm."); + } + + // Copy ALL extensions from the existing certificate + // This preserves SKI, AKI, BasicConstraints, KeyUsage, SANs, CDP, AIA, etc. + foreach (var ext in existingCert.Extensions) + { + certRequest.CertificateExtensions.Add(ext); + } + + // Determine validity + var notBefore = request.NotBefore ?? DateTimeOffset.UtcNow; + var originalDuration = existingCert.NotAfter - existingCert.NotBefore; + var notAfter = request.NotAfter ?? notBefore.Add(originalDuration); + + // Clamp to parent's NotAfter + if (parentCert != null && notAfter > parentCert.NotAfter) + { + notAfter = new DateTimeOffset(parentCert.NotAfter.ToUniversalTime(), TimeSpan.Zero); + } + + // Sign with new serial + X509Certificate2 newCert; + if (isSelfSigned) + { + // Self-signed root: sign with own key + newCert = certRequest.CreateSelfSigned(notBefore, notAfter); + } + else + { + var serialBytes = RandomNumberGenerator.GetBytes(16); + using var signedCert = certRequest.Create( + parentCert!.SubjectName, + CreateSignatureGenerator(parentCert!), + notBefore, + notAfter, + serialBytes); + + // Attach the SAME private key + if (ecdsaKey != null) + newCert = signedCert.CopyWithPrivateKey(ecdsaKey); + else + newCert = signedCert.CopyWithPrivateKey(rsaKey!); + } + + using (newCert) + { + // Verify the re-signed cert is actually signed by the parent CA + if (!isSelfSigned) + { + var issuerError = VerifyIssuedBy(newCert, parentCert!); + if (issuerError != null) + return CertificateIssuanceResult.Failure(issuerError); + } + + var pfxBytes = newCert.Export(X509ContentType.Pkcs12, request.PfxPassword); + var pem = newCert.ExportCertificatePem(); + + // Update the existing entity in-place so all child relationships + // (issued certs, CRLs, sub-CAs) remain attached to this CA. + _logger.LogInformation("Re-sign: updating CA entity Id={Id} in-place (EntityState={State})", + caEntity.Id, db.Entry(caEntity).State); + + caEntity.X509CertificatePem = pem; + caEntity.EncryptedPfxBytes = pfxBytes; + caEntity.PfxPassword = request.PfxPassword; + caEntity.Thumbprint = newCert.Thumbprint; + caEntity.SerialNumber = newCert.SerialNumber; + caEntity.NotBefore = newCert.NotBefore.ToUniversalTime(); + caEntity.NotAfter = newCert.NotAfter.ToUniversalTime(); + + _logger.LogInformation("Re-sign: after property update EntityState={State}, NotAfter={NotAfter}", + db.Entry(caEntity).State, caEntity.NotAfter); + + await db.SaveChangesAsync(); + + _logger.LogInformation("Re-sign: SaveChanges complete for CA Id={Id}", caEntity.Id); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = caEntity.Id, + EntityType = "CaCertificate", + Thumbprint = newCert.Thumbprint, + SerialNumber = newCert.SerialNumber + }; + } + } + catch (Exception ex) + { + return CertificateIssuanceResult.Failure($"Re-sign failed: {ex.Message}"); + } + finally + { + existingCert.Dispose(); + parentCert?.Dispose(); + } + } + + /// + /// Issues a certificate using a remote signing provider (Vault Transit, Cloud KMS). + /// Uses BouncyCastle's X509V3CertificateGenerator with a pluggable ISignatureFactory + /// so the private key never leaves the provider boundary. + /// + private async Task IssueCertificateRemoteAsync( + SigilDbContext db, + CertificateTemplate template, + CertificateIssuanceRequest request, + bool isSelfSigned, + X509Certificate2? issuingCert, + CaCertificate? issuingCaEntity, + SigningKeyReference? issuerKeyRef) + { + try + { + var hashAlg = template.HashAlgorithm?.ToUpperInvariant() switch + { + "SHA384" => HashAlgorithmName.SHA384, + "SHA512" => HashAlgorithmName.SHA512, + _ => HashAlgorithmName.SHA256 + }; + + // Generate new key in the remote provider + var newKeyRef = await _signingProvider.GenerateKeyAsync( + template.KeyAlgorithm, template.KeySize, template.EcdsaCurve); + + // Get the public key to build extensions + using var publicKey = await _signingProvider.GetPublicKeyAsync(newKeyRef); + + // Build extensions using the same logic as local path + var extensions = BuildExtensionsForRemote(template, request, publicKey, issuingCert); + + // Determine validity + var notBefore = request.NotBefore ?? DateTimeOffset.UtcNow; + var notAfter = request.NotAfter ?? notBefore.AddDays(template.ValidityDays); + if (issuingCert != null && notAfter > issuingCert.NotAfter) + notAfter = new DateTimeOffset(issuingCert.NotAfter.ToUniversalTime(), TimeSpan.Zero); + + // For self-signed: sign with own key. For CA-signed: sign with issuer's key. + var signingKeyRef = isSelfSigned ? newKeyRef : (issuerKeyRef ?? newKeyRef); + + X509Certificate2 cert; + if (isSelfSigned) + { + cert = await RemoteCertificateBuilder.CreateSelfSignedAsync( + _signingProvider, signingKeyRef, request.SubjectDn, + notBefore, notAfter, extensions, hashAlg); + } + else + { + cert = await RemoteCertificateBuilder.CreateSignedAsync( + _signingProvider, signingKeyRef, issuingCert!, + publicKey, request.SubjectDn, + notBefore, notAfter, extensions, hashAlg); + } + + using (cert) + { + // Verify issuer relationship + if (!isSelfSigned) + { + var issuerError = VerifyIssuedBy(cert, issuingCert!); + if (issuerError != null) + return CertificateIssuanceResult.Failure(issuerError); + } + + var pem = cert.ExportCertificatePem(); + var certName = string.IsNullOrWhiteSpace(request.CertificateName) + ? ExtractCnFromDn(request.SubjectDn) ?? request.SubjectDn + : request.CertificateName; + + int keySize = GetKeySize(cert); + bool isCaType = template.CertificateType is CertificateType.RootCa or CertificateType.IntermediateCa; + + if (isCaType) + { + var caEntity = new CaCertificate + { + CommunityId = request.CommunityId, + ParentId = isSelfSigned ? null : request.IssuingCaCertificateId, + Name = certName, + Subject = cert.Subject, + X509CertificatePem = pem, + EncryptedPfxBytes = null, // No PFX — key is in Vault + PfxPassword = null, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = template.KeyAlgorithm, + KeySize = keySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CrlDistributionPoint = request.CdpUrls.Count > 0 ? string.Join(";", request.CdpUrls) : null, + AuthorityInfoAccessUri = request.AiaUrls.Count > 0 ? string.Join(";", request.AiaUrls) : null, + CertSecurityLevel = CertSecurityLevel.CloudKms, + StoreProviderHint = $"{_signingProvider.ProviderName}:{newKeyRef.KeyIdentifier}", + }; + + db.CaCertificates.Add(caEntity); + await db.SaveChangesAsync(); + + await PublishCaCertAndCrlAsync(request.CommunityId, certName, cert.RawData); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = caEntity.Id, + EntityType = "CaCertificate", + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber + }; + } + else + { + var sanString = request.SubjectAltNames.Count > 0 + ? string.Join(";", request.SubjectAltNames.Select(s => $"{s.Type}:{s.Value}")) + : null; + + var issuedEntity = new IssuedCertificate + { + IssuingCaCertificateId = request.IssuingCaCertificateId!.Value, + TemplateId = request.TemplateId, + Name = certName, + Subject = cert.Subject, + SubjectAltNames = sanString, + X509CertificatePem = pem, + EncryptedPfxBytes = null, // No PFX — key is in Vault + PfxPassword = null, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = template.KeyAlgorithm, + KeySize = keySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CertSecurityLevel = CertSecurityLevel.CloudKms, + StoreProviderHint = $"{_signingProvider.ProviderName}:{newKeyRef.KeyIdentifier}", + }; + + db.IssuedCertificates.Add(issuedEntity); + await db.SaveChangesAsync(); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = issuedEntity.Id, + EntityType = "IssuedCertificate", + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber + }; + } + } + } + catch (Exception ex) + { + return CertificateIssuanceResult.Failure($"Remote certificate generation failed: {ex.Message}"); + } + finally + { + issuingCert?.Dispose(); + } + } + + /// + /// Issues a certificate with a LOCAL private key but signed by a REMOTE provider (Vault Transit). + /// This is the hybrid path: the end-entity gets a PFX (exportable private key), + /// while the issuing CA's signature comes from the remote signing provider. + /// + private async Task IssueCertificateHybridAsync( + SigilDbContext db, + CertificateTemplate template, + CertificateIssuanceRequest request, + X509Certificate2 issuingCert, + CaCertificate issuingCaEntity, + SigningKeyReference issuerKeyRef) + { + try + { + var hashAlg = template.HashAlgorithm?.ToUpperInvariant() switch + { + "SHA384" => HashAlgorithmName.SHA384, + "SHA512" => HashAlgorithmName.SHA512, + _ => HashAlgorithmName.SHA256 + }; + + // Generate key pair LOCALLY + using var keyHolder = GenerateKeyPair(template); + AsymmetricAlgorithm localKey = (AsymmetricAlgorithm?)keyHolder.Ecdsa ?? keyHolder.Rsa!; + + // Build extensions using local public key + var extensions = BuildExtensionsForRemote(template, request, localKey, issuingCert); + + // Determine validity + var notBefore = request.NotBefore ?? DateTimeOffset.UtcNow; + var notAfter = request.NotAfter ?? notBefore.AddDays(template.ValidityDays); + if (notAfter > issuingCert.NotAfter) + notAfter = new DateTimeOffset(issuingCert.NotAfter.ToUniversalTime(), TimeSpan.Zero); + + // Sign via remote provider (issuer's key is in Vault) but subject key is local + var cert = await RemoteCertificateBuilder.CreateSignedAsync( + _signingProvider, issuerKeyRef, issuingCert, + localKey, request.SubjectDn, + notBefore, notAfter, extensions, hashAlg); + + // Attach the LOCAL private key so we can export as PFX + X509Certificate2 certWithKey; + if (keyHolder.Ecdsa != null) + certWithKey = cert.CopyWithPrivateKey(keyHolder.Ecdsa); + else + certWithKey = cert.CopyWithPrivateKey(keyHolder.Rsa!); + cert.Dispose(); + + using (certWithKey) + { + // Verify issuer relationship + var issuerError = VerifyIssuedBy(certWithKey, issuingCert); + if (issuerError != null) + return CertificateIssuanceResult.Failure(issuerError); + + var pfxBytes = certWithKey.Export(X509ContentType.Pkcs12, request.PfxPassword); + var pem = certWithKey.ExportCertificatePem(); + + var certName = string.IsNullOrWhiteSpace(request.CertificateName) + ? ExtractCnFromDn(request.SubjectDn) ?? request.SubjectDn + : request.CertificateName; + + int keySize = GetKeySize(certWithKey); + bool isCaType = template.CertificateType is CertificateType.RootCa or CertificateType.IntermediateCa; + + if (isCaType) + { + var caEntity = new CaCertificate + { + CommunityId = request.CommunityId, + ParentId = request.IssuingCaCertificateId, + Name = certName, + Subject = certWithKey.Subject, + X509CertificatePem = pem, + EncryptedPfxBytes = pfxBytes, + PfxPassword = request.PfxPassword, + Thumbprint = certWithKey.Thumbprint, + SerialNumber = certWithKey.SerialNumber, + KeyAlgorithm = template.KeyAlgorithm, + KeySize = keySize, + NotBefore = certWithKey.NotBefore.ToUniversalTime(), + NotAfter = certWithKey.NotAfter.ToUniversalTime(), + CrlDistributionPoint = request.CdpUrls.Count > 0 ? string.Join(";", request.CdpUrls) : null, + AuthorityInfoAccessUri = request.AiaUrls.Count > 0 ? string.Join(";", request.AiaUrls) : null, + // Local key — standard software level, no provider hint + }; + + db.CaCertificates.Add(caEntity); + await db.SaveChangesAsync(); + + await PublishCaCertAndCrlAsync(request.CommunityId, certName, certWithKey.RawData); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = caEntity.Id, + EntityType = "CaCertificate", + Thumbprint = certWithKey.Thumbprint, + SerialNumber = certWithKey.SerialNumber + }; + } + else + { + var sanString = request.SubjectAltNames.Count > 0 + ? string.Join(";", request.SubjectAltNames.Select(s => $"{s.Type}:{s.Value}")) + : null; + + var issuedEntity = new IssuedCertificate + { + IssuingCaCertificateId = request.IssuingCaCertificateId!.Value, + TemplateId = request.TemplateId, + Name = certName, + Subject = certWithKey.Subject, + SubjectAltNames = sanString, + X509CertificatePem = pem, + EncryptedPfxBytes = pfxBytes, + PfxPassword = request.PfxPassword, + Thumbprint = certWithKey.Thumbprint, + SerialNumber = certWithKey.SerialNumber, + KeyAlgorithm = template.KeyAlgorithm, + KeySize = keySize, + NotBefore = certWithKey.NotBefore.ToUniversalTime(), + NotAfter = certWithKey.NotAfter.ToUniversalTime(), + // Local key — no StoreProviderHint, standard security level + }; + + db.IssuedCertificates.Add(issuedEntity); + await db.SaveChangesAsync(); + + return new CertificateIssuanceResult + { + Success = true, + EntityId = issuedEntity.Id, + EntityType = "IssuedCertificate", + Thumbprint = certWithKey.Thumbprint, + SerialNumber = certWithKey.SerialNumber + }; + } + } + } + catch (Exception ex) + { + return CertificateIssuanceResult.Failure($"Hybrid certificate generation failed: {ex.Message}"); + } + finally + { + issuingCert.Dispose(); + } + } + + /// + /// Builds X509Extensions for the remote signing path using .NET's extension types. + /// These are later converted to BouncyCastle format by RemoteCertificateBuilder. + /// + private static X509Extension[] BuildExtensionsForRemote( + CertificateTemplate template, + CertificateIssuanceRequest request, + AsymmetricAlgorithm subjectPublicKey, + X509Certificate2? issuingCert) + { + var extensions = new List(); + + // BasicConstraints + bool hasPathLength = template.IsBasicConstraintsCa && template.PathLengthConstraint.HasValue; + extensions.Add(new X509BasicConstraintsExtension( + template.IsBasicConstraintsCa, + hasPathLength, + hasPathLength ? template.PathLengthConstraint!.Value : 0, + template.IsBasicConstraintsCritical)); + + // Key Usage + var keyUsage = (X509KeyUsageFlags)template.KeyUsageFlags; + extensions.Add(new X509KeyUsageExtension(keyUsage, template.IsKeyUsageCritical)); + + // Subject Key Identifier — need a temporary CertificateRequest to get the PublicKey + var tempRequest = subjectPublicKey is ECDsa ecdsa + ? new CertificateRequest("CN=temp", ecdsa, HashAlgorithmName.SHA256) + : new CertificateRequest("CN=temp", (RSA)subjectPublicKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + extensions.Add(new X509SubjectKeyIdentifierExtension(tempRequest.PublicKey, false)); + + // Authority Key Identifier + if (issuingCert != null) + { + CertificateExtensionHelpers.AddAuthorityKeyIdentifierToList(issuingCert, extensions); + } + + // Extended Key Usage + if (!string.IsNullOrWhiteSpace(template.ExtendedKeyUsageOids)) + { + var oids = new OidCollection(); + foreach (var oid in template.ExtendedKeyUsageOids.Split(';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + oids.Add(new Oid(oid)); + if (oids.Count > 0) + extensions.Add(new X509EnhancedKeyUsageExtension(oids, template.IsExtendedKeyUsageCritical)); + } + + // CDP + if (template.IncludeCdp && request.CdpUrls.Count > 0) + extensions.Add(CertificateExtensionHelpers.MakeCdp(request.CdpUrls)); + + // AIA + if (template.IncludeAia && request.AiaUrls.Count > 0) + extensions.Add(CertificateExtensionHelpers.BuildAiaExtension( + request.AiaUrls.Select(u => new Uri(u)).ToList())); + + // SANs + if (request.SubjectAltNames.Count > 0) + { + var sanBuilder = new SubjectAlternativeNameBuilder(); + foreach (var san in request.SubjectAltNames) + { + switch (san.Type) + { + case SanType.Uri: sanBuilder.AddUri(new Uri(san.Value)); break; + case SanType.Dns: sanBuilder.AddDnsName(san.Value); break; + case SanType.Email: sanBuilder.AddEmailAddress(san.Value); break; + case SanType.IpAddress: sanBuilder.AddIpAddress(System.Net.IPAddress.Parse(san.Value)); break; + } + } + extensions.Add(sanBuilder.Build()); + } + + return extensions.ToArray(); + } + + /// + /// Verifies that was signed by + /// using BouncyCastle signature verification. Returns null on success or an error message. + /// + public static string? VerifyIssuedBy(X509Certificate2 cert, X509Certificate2 issuer) + { + try + { + var bcParser = new Org.BouncyCastle.X509.X509CertificateParser(); + var bcCert = bcParser.ReadCertificate(cert.RawData); + var bcIssuer = bcParser.ReadCertificate(issuer.RawData); + + // Check Issuer DN matches Subject DN of the alleged issuer + if (!bcCert.IssuerDN.Equivalent(bcIssuer.SubjectDN)) + { + return $"Certificate Issuer DN '{bcCert.IssuerDN}' does not match the CA Subject DN '{bcIssuer.SubjectDN}'."; + } + + // Verify the signature + bcCert.Verify(bcIssuer.GetPublicKey()); + return null; // success + } + catch (Org.BouncyCastle.Security.InvalidKeyException) + { + return "Certificate signature verification failed: the certificate was not signed by the specified CA."; + } + catch (Org.BouncyCastle.Security.Certificates.CertificateException ex) + { + return $"Certificate signature verification failed: {ex.Message}"; + } + catch (Exception ex) + { + return $"Certificate issuer validation failed: {ex.Message}"; + } + } + + private static KeyHolder GenerateKeyPair(CertificateTemplate template) + { + if (template.KeyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + var curve = template.EcdsaCurve?.ToLowerInvariant() switch + { + "nistp256" => ECCurve.NamedCurves.nistP256, + "nistp384" => ECCurve.NamedCurves.nistP384, + "nistp521" => ECCurve.NamedCurves.nistP521, + _ => ECCurve.NamedCurves.nistP384 // default + }; + + return new KeyHolder(ecdsa: ECDsa.Create(curve)); + } + + return new KeyHolder(rsa: RSA.Create(template.KeySize)); + } + + private static CertificateRequest CreateCertificateRequest( + CertificateTemplate template, + string subjectDn, + KeyHolder keyHolder) + { + var hashAlg = template.HashAlgorithm?.ToUpperInvariant() switch + { + "SHA384" => HashAlgorithmName.SHA384, + "SHA512" => HashAlgorithmName.SHA512, + _ => HashAlgorithmName.SHA256 + }; + + if (keyHolder.Ecdsa != null) + { + return new CertificateRequest(subjectDn, keyHolder.Ecdsa, hashAlg); + } + + return new CertificateRequest( + subjectDn, + keyHolder.Rsa!, + hashAlg, + RSASignaturePadding.Pkcs1); + } + + private static void AddExtensions( + CertificateRequest certRequest, + CertificateTemplate template, + CertificateIssuanceRequest request, + X509Certificate2? issuingCert) + { + // Basic Constraints + bool hasPathLength = template.IsBasicConstraintsCa && template.PathLengthConstraint.HasValue; + certRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + template.IsBasicConstraintsCa, + hasPathLength, + hasPathLength ? template.PathLengthConstraint!.Value : 0, + template.IsBasicConstraintsCritical)); + + // Key Usage + var keyUsage = (X509KeyUsageFlags)template.KeyUsageFlags; + certRequest.CertificateExtensions.Add( + new X509KeyUsageExtension(keyUsage, template.IsKeyUsageCritical)); + + // Subject Key Identifier + certRequest.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(certRequest.PublicKey, false)); + + // Authority Key Identifier (if issued by a CA) + if (issuingCert != null) + { + CertificateExtensionHelpers.AddAuthorityKeyIdentifier(issuingCert, certRequest); + } + + // Extended Key Usage + if (!string.IsNullOrWhiteSpace(template.ExtendedKeyUsageOids)) + { + var oids = new OidCollection(); + foreach (var oid in template.ExtendedKeyUsageOids.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + oids.Add(new Oid(oid)); + } + + if (oids.Count > 0) + { + certRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension(oids, template.IsExtendedKeyUsageCritical)); + } + } + + // CRL Distribution Points + if (template.IncludeCdp && request.CdpUrls.Count > 0) + { + certRequest.CertificateExtensions.Add( + CertificateExtensionHelpers.MakeCdp(request.CdpUrls)); + } + + // Authority Information Access + if (template.IncludeAia && request.AiaUrls.Count > 0) + { + certRequest.CertificateExtensions.Add( + CertificateExtensionHelpers.BuildAiaExtension( + request.AiaUrls.Select(u => new Uri(u)).ToList())); + } + + // Subject Alternative Names + if (request.SubjectAltNames.Count > 0) + { + var sanBuilder = new SubjectAlternativeNameBuilder(); + foreach (var san in request.SubjectAltNames) + { + switch (san.Type) + { + case SanType.Uri: + sanBuilder.AddUri(new Uri(san.Value)); + break; + case SanType.Dns: + sanBuilder.AddDnsName(san.Value); + break; + case SanType.Email: + sanBuilder.AddEmailAddress(san.Value); + break; + case SanType.IpAddress: + sanBuilder.AddIpAddress(IPAddress.Parse(san.Value)); + break; + } + } + + certRequest.CertificateExtensions.Add(sanBuilder.Build()); + } + } + + private static X509SignatureGenerator CreateSignatureGenerator(X509Certificate2 issuerCert) + { + if (issuerCert.GetECDsaPrivateKey() is ECDsa ecdsa) + return X509SignatureGenerator.CreateForECDsa(ecdsa); + + if (issuerCert.GetRSAPrivateKey() is RSA rsa) + return X509SignatureGenerator.CreateForRSA(rsa, RSASignaturePadding.Pkcs1); + + throw new InvalidOperationException($"Unsupported issuer key algorithm: {issuerCert.PublicKey.Oid.FriendlyName}"); + } + + private static X509Certificate2 AttachPrivateKey(X509Certificate2 signedCert, KeyHolder keyHolder) + { + if (keyHolder.Ecdsa != null) + return signedCert.CopyWithPrivateKey(keyHolder.Ecdsa); + + return signedCert.CopyWithPrivateKey(keyHolder.Rsa!); + } + + private async Task PublishCaCertAndCrlAsync( + int communityId, string certName, byte[] certDerBytes) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var baseUrls = await db.CommunityBaseUrls + .Where(bu => bu.CommunityId == communityId && bu.PublishingBasePath != null) + .ToListAsync(); + + if (baseUrls.Count == 0) return; + + foreach (var baseUrl in baseUrls) + { + if (string.IsNullOrEmpty(baseUrl.PublishingBasePath)) continue; + + try + { + var certPath = Path.GetFullPath(Path.Combine(baseUrl.PublishingBasePath, "certs", $"{certName}.cer")); + var certDir = Path.GetDirectoryName(certPath); + if (!string.IsNullOrEmpty(certDir)) + Directory.CreateDirectory(certDir); + + var tempPath = certPath + ".tmp"; + await File.WriteAllBytesAsync(tempPath, certDerBytes); + File.Move(tempPath, certPath, overwrite: true); + _logger.LogInformation("Published certificate to {Path} ({Bytes} bytes)", certPath, certDerBytes.Length); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish certificate for CA '{CertName}'", certName); + } + } + } + + private static int GetKeySize(X509Certificate2 cert) + { + if (cert.GetECDsaPublicKey() is { } ecdsa) + { + using (ecdsa) + return ecdsa.KeySize; + } + + if (cert.GetRSAPublicKey() is { } rsa) + { + using (rsa) + return rsa.KeySize; + } + + return 0; + } + + private static string? ExtractCnFromDn(string dn) + { + // Simple CN extraction from "CN=value, O=..." + foreach (var part in dn.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + return trimmed[3..].Trim(); + } + + return null; + } + + /// + /// Holds the generated key pair (either RSA or ECDSA) and disposes it properly. + /// + private sealed class KeyHolder : IDisposable + { + public RSA? Rsa { get; } + public ECDsa? Ecdsa { get; } + + public KeyHolder(RSA? rsa = null, ECDsa? ecdsa = null) + { + Rsa = rsa; + Ecdsa = ecdsa; + } + + public void Dispose() + { + Rsa?.Dispose(); + Ecdsa?.Dispose(); + } + } +} diff --git a/examples/CA/Sigil.Common/Services/CertificateParsingService.cs b/examples/CA/Sigil.Common/Services/CertificateParsingService.cs new file mode 100644 index 000000000..a66c54a37 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/CertificateParsingService.cs @@ -0,0 +1,179 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; + +namespace Sigil.Common.Services; + +public enum DetectedCertRole +{ + RootCa, + IntermediateCa, + EndEntity +} + +public class ParsedCertificate +{ + public X509Certificate2 Certificate { get; set; } = null!; + public byte[] RawFileBytes { get; set; } = []; + public string FileName { get; set; } = string.Empty; + public DetectedCertRole DetectedRole { get; set; } + public string? SubjectKeyIdentifier { get; set; } + public string? AuthorityKeyIdentifier { get; set; } + public string? SubjectAltNames { get; set; } + public string Algorithm { get; set; } = "Unknown"; + public int KeySize { get; set; } + public bool HasPrivateKey { get; set; } +} + +public class CertificateParsingService +{ + /// + /// Parses a certificate file (.pfx, .cer, .pem) and returns structured info. + /// + public ParsedCertificate? Parse(byte[] fileBytes, string fileName, string? password = null) + { + X509Certificate2? cert = null; + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + + try + { + if (ext == ".pfx" || ext == ".p12") + { + cert = X509CertificateLoader.LoadPkcs12(fileBytes, password, + X509KeyStorageFlags.Exportable); + } + else if (ext is ".cer" or ".crt" or ".der" or ".pem") + { + // Try DER first, then PEM + try + { + cert = X509CertificateLoader.LoadCertificate(fileBytes); + } + catch + { + var pem = System.Text.Encoding.UTF8.GetString(fileBytes); + cert = X509Certificate2.CreateFromPem(pem); + } + } + else + { + // Try PEM first, then DER + try + { + cert = X509CertificateLoader.LoadCertificate(fileBytes); + } + catch + { + cert = X509CertificateLoader.LoadPkcs12(fileBytes, password, + X509KeyStorageFlags.Exportable); + } + } + } + catch + { + return null; + } + + if (cert == null) return null; + + var (algorithm, keySize) = GetKeyInfo(cert); + + return new ParsedCertificate + { + Certificate = cert, + RawFileBytes = fileBytes, + FileName = fileName, + DetectedRole = DetectRole(cert), + SubjectKeyIdentifier = GetSubjectKeyIdentifier(cert), + AuthorityKeyIdentifier = GetAuthorityKeyIdentifier(cert), + SubjectAltNames = GetSubjectAltNames(cert), + Algorithm = algorithm, + KeySize = keySize, + HasPrivateKey = cert.HasPrivateKey + }; + } + + private static DetectedCertRole DetectRole(X509Certificate2 cert) + { + var bcExt = cert.Extensions["2.5.29.19"]; // BasicConstraints + if (bcExt != null) + { + var bc = new X509BasicConstraintsExtension(bcExt, bcExt.Critical); + if (bc.CertificateAuthority) + { + // Self-signed = root, otherwise intermediate + return cert.Subject == cert.Issuer + ? DetectedCertRole.RootCa + : DetectedCertRole.IntermediateCa; + } + } + + return DetectedCertRole.EndEntity; + } + + private static string? GetSubjectKeyIdentifier(X509Certificate2 cert) + { + var skiExt = cert.Extensions["2.5.29.14"]; + if (skiExt == null) return null; + var ski = new X509SubjectKeyIdentifierExtension(skiExt, skiExt.Critical); + return ski.SubjectKeyIdentifier; + } + + private static string? GetAuthorityKeyIdentifier(X509Certificate2 cert) + { + var akiExt = cert.Extensions["2.5.29.35"]; + if (akiExt?.RawData == null || akiExt.RawData.Length < 6) return null; + + try + { + var data = akiExt.RawData; + int offset = 2; // skip SEQUENCE tag + length + if (data[offset] == 0x80) // [0] implicit tag + { + var len = data[offset + 1]; + var keyId = new byte[len]; + Array.Copy(data, offset + 2, keyId, 0, len); + return Convert.ToHexString(keyId); + } + } + catch { } + + return null; + } + + private static string? GetSubjectAltNames(X509Certificate2 cert) + { + var sanExt = cert.Extensions["2.5.29.17"]; + if (sanExt == null) return null; + + try + { + var formatted = sanExt.Format(multiLine: true); + var lines = formatted.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return string.Join("; ", lines); + } + catch + { + return null; + } + } + + private static (string algorithm, int keySize) GetKeyInfo(X509Certificate2 cert) + { + var rsa = cert.GetRSAPublicKey(); + if (rsa != null) return ("RSA", rsa.KeySize); + + var ecdsa = cert.GetECDsaPublicKey(); + if (ecdsa != null) return ("ECDSA", ecdsa.KeySize); + + return ("Unknown", 0); + } +} diff --git a/examples/CA/Sigil.Common/Services/ChainValidationService.cs b/examples/CA/Sigil.Common/Services/ChainValidationService.cs new file mode 100644 index 000000000..c30de43f9 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/ChainValidationService.cs @@ -0,0 +1,1105 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.X509; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using BcX509Certificate = Org.BouncyCastle.X509.X509Certificate; + +namespace Sigil.Common.Services; + +public class ChainValidationService +{ + private readonly IDbContextFactory _dbFactory; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public ChainValidationService( + IDbContextFactory dbFactory, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _dbFactory = dbFactory; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + /// Validates all certificates in a community in one pass (parses CAs once). + /// Uses stored CRLs only (no HTTP) for speed. + /// Returns a dictionary keyed by thumbprint. + /// + public async Task> ValidateCommunityAsync( + int communityId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var allCas = await db.CaCertificates + .Where(c => c.CommunityId == communityId) + .Include(c => c.IssuedCertificates) + .ToListAsync(ct); + + var allCrls = await db.Crls + .Where(c => c.CaCertificate.CommunityId == communityId) + .Include(c => c.Revocations) + .ToListAsync(ct); + + // Parse all CA certs once — the expensive part + var parser = new X509CertificateParser(); + var bcCas = new List<(CaCertificate entity, BcX509Certificate bcCert)>(); + foreach (var ca in allCas) + { + try + { + using var dotNetCa = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var bcCa = parser.ReadCertificate(dotNetCa.RawData); + bcCas.Add((ca, bcCa)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cannot parse CA certificate {Name}", ca.Name); + } + } + + // Build list of all certs to validate + var validationTasks = new List<(string Thumbprint, Task Task)>(); + + foreach (var ca in allCas) + { + validationTasks.Add((ca.Thumbprint, ValidateChainInternal( + ca.X509CertificatePem, ca.Name, bcCas, allCrls, skipOnlineCrl: false, ct))); + } + + foreach (var ca in allCas) + { + foreach (var issued in ca.IssuedCertificates) + { + validationTasks.Add((issued.Thumbprint, ValidateChainInternal( + issued.X509CertificatePem, issued.Name, bcCas, allCrls, skipOnlineCrl: false, ct))); + } + } + + // Run all validations in parallel (CRL downloads happen concurrently) + await Task.WhenAll(validationTasks.Select(t => t.Task)); + + var results = new Dictionary(); + foreach (var (thumbprint, task) in validationTasks) + { + results[thumbprint] = task.Result; + } + + return results; + } + + public async Task ValidateCaCertificateAsync( + int caCertificateId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var caCert = await db.CaCertificates + .Include(c => c.Community) + .FirstOrDefaultAsync(c => c.Id == caCertificateId, ct); + + if (caCert == null) + return ChainValidationResult.Failed("Certificate not found"); + + var allCas = await db.CaCertificates + .Where(c => c.CommunityId == caCert.CommunityId) + .ToListAsync(ct); + + var allCrls = await db.Crls + .Where(c => c.CaCertificate.CommunityId == caCert.CommunityId) + .Include(c => c.Revocations) + .ToListAsync(ct); + + return await ValidateChainAsync(caCert.X509CertificatePem, caCert.Name, allCas, allCrls, ct); + } + + public async Task ValidateIssuedCertificateAsync( + int issuedCertificateId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var issued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .ThenInclude(ca => ca.Community) + .FirstOrDefaultAsync(i => i.Id == issuedCertificateId, ct); + + if (issued == null) + return ChainValidationResult.Failed("Certificate not found"); + + var communityId = issued.IssuingCaCertificate.CommunityId; + + var allCas = await db.CaCertificates + .Where(c => c.CommunityId == communityId) + .ToListAsync(ct); + + var allCrls = await db.Crls + .Where(c => c.CaCertificate.CommunityId == communityId) + .Include(c => c.Revocations) + .ToListAsync(ct); + + return await ValidateChainAsync(issued.X509CertificatePem, issued.Name, allCas, allCrls, ct); + } + + /// + /// Validates a certificate chain using only online resolution (CDP for CRLs, AIA for + /// intermediate certs). The only data used from the database is the root CA trust anchor(s) + /// for the community. This simulates how an external relying party would validate the chain. + /// + public async Task ValidateOnlineAsync( + string leafPem, string leafName, int communityId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + // Only load root CAs as trust anchors + var rootCas = await db.CaCertificates + .Where(c => c.CommunityId == communityId && c.ParentId == null) + .ToListAsync(ct); + + if (rootCas.Count == 0) + return ChainValidationResult.Failed("No root CA trust anchors found in this community"); + + var parser = new X509CertificateParser(); + + // Parse root CAs + var trustedRoots = new List<(CaCertificate entity, BcX509Certificate bcCert)>(); + foreach (var root in rootCas) + { + try + { + using var dotNetCa = X509Certificate2.CreateFromPem(root.X509CertificatePem); + var bcCa = parser.ReadCertificate(dotNetCa.RawData); + trustedRoots.Add((root, bcCa)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cannot parse root CA certificate {Name}", root.Name); + } + } + + // Parse the leaf + BcX509Certificate bcLeaf; + try + { + using var dotNetLeaf = X509Certificate2.CreateFromPem(leafPem); + bcLeaf = parser.ReadCertificate(dotNetLeaf.RawData); + } + catch (Exception ex) + { + return ChainValidationResult.Failed($"Cannot parse certificate: {ex.Message}"); + } + + // Walk the chain, resolving intermediates via AIA and CRLs via CDP + var chainLinks = new List(); + var resolvedCas = new List<(CaCertificate entity, BcX509Certificate bcCert)>(trustedRoots); + var current = bcLeaf; + var currentName = leafName; + var visited = new HashSet(); + bool reachedRoot = false; + const int maxDepth = 10; + + for (int depth = 0; depth < maxDepth; depth++) + { + var thumbprint = Convert.ToHexString(current.GetEncoded().Take(20).ToArray()); + if (!visited.Add(thumbprint)) + { + chainLinks.Add(ChainLink.Problem(currentName, current, "Circular chain detected")); + break; + } + + var link = new ChainLink + { + Name = currentName, + Subject = current.SubjectDN.ToString(), + Issuer = current.IssuerDN.ToString() + }; + + // Check time validity + var now = DateTime.UtcNow; + if (now < current.NotBefore.ToUniversalTime()) + link.Problems.Add("Not yet valid (NotBefore is in the future)"); + if (now > current.NotAfter.ToUniversalTime()) + link.Problems.Add($"Expired (NotAfter: {current.NotAfter:yyyy-MM-dd})"); + + // Check basic constraints for non-leaf certs + if (depth > 0) + { + var basicConstraints = current.GetBasicConstraints(); + if (basicConstraints < 0) + link.Problems.Add("Not a CA (BasicConstraints CA=false or missing)"); + } + + // Self-signed root + if (current.IssuerDN.Equivalent(current.SubjectDN)) + { + try + { + current.Verify(current.GetPublicKey()); + link.SignatureValid = true; + } + catch + { + link.Problems.Add("Self-signature verification failed"); + link.SignatureValid = false; + } + + var matchedRoot = trustedRoots.FirstOrDefault(ca => + ca.bcCert.SubjectDN.Equivalent(current.SubjectDN)); + if (matchedRoot.entity != null) + { + link.IsTrustAnchor = true; + reachedRoot = true; + } + else + { + link.Problems.Add("Root CA not found in community trust store"); + } + + chainLinks.Add(link); + break; + } + + // Find issuer in already-resolved certs + BcX509Certificate? issuerBc = null; + string? issuerName = null; + + foreach (var (caEntity, caCert) in resolvedCas) + { + if (MatchesKeyIdentifiers(current, caCert) || caCert.SubjectDN.Equivalent(current.IssuerDN)) + { + try + { + current.Verify(caCert.GetPublicKey()); + issuerBc = caCert; + issuerName = caEntity.Name; + link.SignatureValid = true; + break; + } + catch { } + } + } + + // If issuer not found locally, try to resolve via AIA + if (issuerBc == null) + { + var aiaResult = await ResolveIssuerViaAiaAsync(current, ct); + if (aiaResult != null) + { + issuerBc = aiaResult.Value.bcCert; + issuerName = aiaResult.Value.bcCert.SubjectDN.ToString(); + link.AiaResolved = true; + link.AiaResolvedUrl = aiaResult.Value.url; + + // Add to resolved set so deeper links can find it + resolvedCas.Add((new CaCertificate { Name = issuerName }, aiaResult.Value.bcCert)); + + try + { + current.Verify(issuerBc.GetPublicKey()); + link.SignatureValid = true; + } + catch + { + link.SignatureValid = false; + link.Problems.Add("Signature verification against AIA-resolved issuer failed"); + } + } + } + + if (issuerBc == null) + { + link.SignatureValid = false; + link.Problems.Add("Issuer not found — no AIA extension or AIA download failed"); + chainLinks.Add(link); + break; + } + + // Check CRL revocation online only — must happen after issuer resolution + // because the CRL signature is verified against the issuer's public key + await CheckCrlRevocationOnlineAsync(current, link, resolvedCas, ct); + + chainLinks.Add(link); + current = issuerBc; + currentName = issuerName ?? issuerBc.SubjectDN.ToString(); + } + + var isValid = reachedRoot && chainLinks.All(l => l.Problems.Count == 0); + + return new ChainValidationResult + { + IsValid = isValid, + ReachedTrustAnchor = reachedRoot, + ChainLinks = chainLinks + }; + } + + /// + /// Online-only validation entry point for a CA certificate by ID. + /// + public async Task ValidateCaCertificateOnlineAsync( + int caCertificateId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var caCert = await db.CaCertificates.FindAsync(new object[] { caCertificateId }, ct); + if (caCert == null) return ChainValidationResult.Failed("Certificate not found"); + return await ValidateOnlineAsync(caCert.X509CertificatePem, caCert.Name, caCert.CommunityId, ct); + } + + /// + /// Online-only validation entry point for an issued certificate by ID. + /// + public async Task ValidateIssuedCertificateOnlineAsync( + int issuedCertificateId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var issued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(i => i.Id == issuedCertificateId, ct); + if (issued == null) return ChainValidationResult.Failed("Certificate not found"); + return await ValidateOnlineAsync(issued.X509CertificatePem, issued.Name, + issued.IssuingCaCertificate.CommunityId, ct); + } + + public async Task ValidateChainAsync( + string leafPem, + string leafName, + List communityCas, + List communityCrls, + CancellationToken ct = default) + { + return await ValidateChainAsync(leafPem, leafName, communityCas, communityCrls, + skipOnlineCrl: false, ct); + } + + public async Task ValidateChainAsync( + string leafPem, + string leafName, + List communityCas, + List communityCrls, + bool skipOnlineCrl, + CancellationToken ct = default) + { + var parser = new X509CertificateParser(); + var bcCas = new List<(CaCertificate entity, BcX509Certificate bcCert)>(); + foreach (var ca in communityCas) + { + try + { + using var dotNetCa = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var bcCa = parser.ReadCertificate(dotNetCa.RawData); + bcCas.Add((ca, bcCa)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cannot parse CA certificate {Name}", ca.Name); + } + } + + return await ValidateChainInternal(leafPem, leafName, bcCas, communityCrls, skipOnlineCrl, ct); + } + + private async Task ValidateChainInternal( + string leafPem, + string leafName, + List<(CaCertificate entity, BcX509Certificate bcCert)> bcCas, + List communityCrls, + bool skipOnlineCrl, + CancellationToken ct) + { + var parser = new X509CertificateParser(); + var chainLinks = new List(); + + BcX509Certificate bcLeaf; + try + { + using var dotNetLeaf = X509Certificate2.CreateFromPem(leafPem); + bcLeaf = parser.ReadCertificate(dotNetLeaf.RawData); + } + catch (Exception ex) + { + return ChainValidationResult.Failed($"Cannot parse certificate: {ex.Message}"); + } + + var current = bcLeaf; + var currentName = leafName; + var visited = new HashSet(); + bool reachedRoot = false; + const int maxDepth = 10; + + for (int depth = 0; depth < maxDepth; depth++) + { + var thumbprint = Convert.ToHexString(current.GetEncoded().Take(20).ToArray()); + if (!visited.Add(thumbprint)) + { + chainLinks.Add(ChainLink.Problem(currentName, current, "Circular chain detected")); + break; + } + + var link = new ChainLink + { + Name = currentName, + Subject = current.SubjectDN.ToString(), + Issuer = current.IssuerDN.ToString() + }; + + // Check time validity + var now = DateTime.UtcNow; + if (now < current.NotBefore.ToUniversalTime()) + link.Problems.Add("Not yet valid (NotBefore is in the future)"); + if (now > current.NotAfter.ToUniversalTime()) + link.Problems.Add($"Expired (NotAfter: {current.NotAfter:yyyy-MM-dd})"); + + // Check basic constraints for non-leaf certs + if (depth > 0) + { + var basicConstraints = current.GetBasicConstraints(); + if (basicConstraints < 0) + link.Problems.Add("Not a CA (BasicConstraints CA=false or missing)"); + } + + // Check CRL revocation (skip for self-signed roots) + if (!current.IssuerDN.Equivalent(current.SubjectDN)) + { + await CheckCrlRevocationAsync(current, link, bcCas, communityCrls, skipOnlineCrl, ct); + } + + // Self-signed root + if (current.IssuerDN.Equivalent(current.SubjectDN)) + { + try + { + current.Verify(current.GetPublicKey()); + link.SignatureValid = true; + } + catch + { + link.Problems.Add("Self-signature verification failed"); + link.SignatureValid = false; + } + + var matchedRoot = bcCas.FirstOrDefault(ca => + ca.bcCert.SubjectDN.Equivalent(current.SubjectDN)); + if (matchedRoot.entity != null) + { + link.IsTrustAnchor = true; + reachedRoot = true; + } + else + { + link.Problems.Add("Root CA not found in community trust store"); + } + + chainLinks.Add(link); + break; + } + + // Find issuer by AKI/SKI + BcX509Certificate? issuerBc = null; + CaCertificate? issuerEntity = null; + + foreach (var (caEntity, caCert) in bcCas) + { + if (MatchesKeyIdentifiers(current, caCert)) + { + try + { + current.Verify(caCert.GetPublicKey()); + issuerBc = caCert; + issuerEntity = caEntity; + link.SignatureValid = true; + break; + } + catch { } + } + } + + // Fallback: DN match + if (issuerBc == null) + { + foreach (var (caEntity, caCert) in bcCas) + { + if (caCert.SubjectDN.Equivalent(current.IssuerDN)) + { + try + { + current.Verify(caCert.GetPublicKey()); + issuerBc = caCert; + issuerEntity = caEntity; + link.SignatureValid = true; + break; + } + catch { } + } + } + } + + if (issuerBc == null) + { + link.SignatureValid = false; + link.Problems.Add("Issuer not found in community — chain is incomplete"); + chainLinks.Add(link); + break; + } + + chainLinks.Add(link); + current = issuerBc; + currentName = issuerEntity?.Name ?? issuerBc.SubjectDN.ToString(); + } + + var isValid = reachedRoot && chainLinks.All(l => l.Problems.Count == 0); + + return new ChainValidationResult + { + IsValid = isValid, + ReachedTrustAnchor = reachedRoot, + ChainLinks = chainLinks + }; + } + + private async Task CheckCrlRevocationAsync( + BcX509Certificate cert, + ChainLink link, + List<(CaCertificate entity, BcX509Certificate bcCert)> bcCas, + List communityCrls, + bool skipOnlineCrl, + CancellationToken ct) + { + // Find issuer + (CaCertificate entity, BcX509Certificate bcCert)? issuerCa = null; + foreach (var ca in bcCas) + { + if (MatchesKeyIdentifiers(cert, ca.bcCert) || ca.bcCert.SubjectDN.Equivalent(cert.IssuerDN)) + { + try + { + cert.Verify(ca.bcCert.GetPublicKey()); + issuerCa = ca; + break; + } + catch { } + } + } + + if (issuerCa == null) + { + link.CrlStatus = CrlCheckStatus.IssuerNotFound; + return; + } + + // Try stored CRLs first + var storedCrls = communityCrls + .Where(c => c.CaCertificateId == issuerCa.Value.entity.Id) + .OrderByDescending(c => c.CrlNumber) + .ToList(); + + if (storedCrls.Count > 0) + { + var latestCrl = storedCrls[0]; + var now = DateTime.UtcNow; + + if (now <= latestCrl.NextUpdate) + { + CheckRevocationInCrl(cert, link, latestCrl, CrlSource.Stored); + return; + } + + _logger.LogInformation("Stored CRL #{Number} expired, attempting online resolution", latestCrl.CrlNumber); + } + + if (skipOnlineCrl) + { + if (storedCrls.Count > 0) + { + link.CrlStatus = CrlCheckStatus.CrlExpired; + link.Problems.Add($"CRL #{storedCrls[0].CrlNumber} expired (online check skipped)"); + } + else + { + link.CrlStatus = CrlCheckStatus.NoCrlAvailable; + } + return; + } + + var cdpUrls = ExtractCdpUrls(cert); + if (cdpUrls.Count == 0) + { + if (storedCrls.Count > 0) + { + link.CrlStatus = CrlCheckStatus.CrlExpired; + link.Problems.Add($"CRL #{storedCrls[0].CrlNumber} expired (NextUpdate: {storedCrls[0].NextUpdate:yyyy-MM-dd}), no CDP URL to fetch fresh CRL"); + } + else + { + link.CrlStatus = CrlCheckStatus.NoCrlAvailable; + link.Problems.Add("No CRL available and no CDP extension for online resolution"); + } + return; + } + + foreach (var url in cdpUrls) + { + try + { + var crlBytes = await DownloadCrlAsync(url, ct); + if (crlBytes == null) continue; + + var crlParser = new X509CrlParser(); + var downloadedCrl = crlParser.ReadCrl(crlBytes); + + try + { + downloadedCrl.Verify(issuerCa.Value.bcCert.GetPublicKey()); + } + catch + { + _logger.LogWarning("Downloaded CRL from {Url} failed signature verification", url); + continue; + } + + var now = DateTime.UtcNow; + if (now < downloadedCrl.ThisUpdate.ToUniversalTime()) + { + _logger.LogWarning("Downloaded CRL from {Url} has ThisUpdate in the future", url); + continue; + } + + if (downloadedCrl.NextUpdate.HasValue && now > downloadedCrl.NextUpdate.Value.ToUniversalTime()) + { + _logger.LogWarning("Downloaded CRL from {Url} is expired", url); + } + + long crlNumber = 0; + var crlNumExt = downloadedCrl.GetExtensionValue(X509Extensions.CrlNumber); + if (crlNumExt != null) + { + var asn1Num = Org.BouncyCastle.X509.Extension.X509ExtensionUtilities + .FromExtensionValue(crlNumExt); + crlNumber = DerInteger.GetInstance(asn1Num).LongValueExact; + } + + var isRevoked = downloadedCrl.IsRevoked(cert); + + if (isRevoked) + { + link.CrlStatus = CrlCheckStatus.Revoked; + link.CrlSource = CrlSource.Downloaded; + link.CrlSourceUrl = url; + + var entry = downloadedCrl.GetRevokedCertificate(cert.SerialNumber); + var reason = GetRevocationReason(entry); + link.Problems.Add($"REVOKED on {entry?.RevocationDate:yyyy-MM-dd} — {reason} (CRL #{crlNumber} from {url})"); + } + else + { + link.CrlStatus = CrlCheckStatus.Good; + link.CrlSource = CrlSource.Downloaded; + link.CrlSourceUrl = url; + } + + _logger.LogInformation("Resolved CRL #{Number} from {Url} for revocation check", crlNumber, url); + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download/parse CRL from {Url}", url); + } + } + + if (storedCrls.Count > 0) + { + var expiredCrl = storedCrls[0]; + link.CrlStatus = CrlCheckStatus.CrlExpired; + link.Problems.Add($"CRL #{expiredCrl.CrlNumber} expired, online resolution from CDP failed"); + } + else + { + link.CrlStatus = CrlCheckStatus.CrlFetchFailed; + link.Problems.Add($"CRL download failed from {string.Join(", ", cdpUrls)}"); + } + } + + /// + /// Checks CRL revocation using only online CDP URLs — does not consult stored CRLs. + /// + private async Task CheckCrlRevocationOnlineAsync( + BcX509Certificate cert, + ChainLink link, + List<(CaCertificate entity, BcX509Certificate bcCert)> resolvedCas, + CancellationToken ct) + { + if (cert.IssuerDN.Equivalent(cert.SubjectDN)) + return; // Self-signed root — no CRL check needed + + // Find issuer for signature verification of the CRL + BcX509Certificate? issuerBcCert = null; + foreach (var (_, caCert) in resolvedCas) + { + if (MatchesKeyIdentifiers(cert, caCert) || caCert.SubjectDN.Equivalent(cert.IssuerDN)) + { + try + { + cert.Verify(caCert.GetPublicKey()); + issuerBcCert = caCert; + break; + } + catch { } + } + } + + if (issuerBcCert == null) + { + link.CrlStatus = CrlCheckStatus.IssuerNotFound; + return; + } + + var cdpUrls = ExtractCdpUrls(cert); + if (cdpUrls.Count == 0) + { + link.CrlStatus = CrlCheckStatus.NoCrlAvailable; + link.Problems.Add("No CDP extension — cannot verify revocation status online"); + return; + } + + foreach (var url in cdpUrls) + { + try + { + var crlBytes = await DownloadCrlAsync(url, ct); + if (crlBytes == null) continue; + + var crlParser = new X509CrlParser(); + var downloadedCrl = crlParser.ReadCrl(crlBytes); + + try + { + downloadedCrl.Verify(issuerBcCert.GetPublicKey()); + } + catch + { + _logger.LogWarning("Online CRL from {Url} failed signature verification", url); + continue; + } + + var now = DateTime.UtcNow; + if (downloadedCrl.NextUpdate.HasValue && now > downloadedCrl.NextUpdate.Value.ToUniversalTime()) + { + link.Problems.Add($"CRL from {url} is expired (NextUpdate: {downloadedCrl.NextUpdate.Value:yyyy-MM-dd})"); + } + + var isRevoked = downloadedCrl.IsRevoked(cert); + if (isRevoked) + { + link.CrlStatus = CrlCheckStatus.Revoked; + link.CrlSource = CrlSource.Downloaded; + link.CrlSourceUrl = url; + var entry = downloadedCrl.GetRevokedCertificate(cert.SerialNumber); + var reason = GetRevocationReason(entry); + long crlNumber = 0; + var crlNumExt = downloadedCrl.GetExtensionValue(X509Extensions.CrlNumber); + if (crlNumExt != null) + { + var asn1Num = Org.BouncyCastle.X509.Extension.X509ExtensionUtilities + .FromExtensionValue(crlNumExt); + crlNumber = DerInteger.GetInstance(asn1Num).LongValueExact; + } + link.Problems.Add($"REVOKED on {entry?.RevocationDate:yyyy-MM-dd} — {reason} (CRL #{crlNumber} from {url})"); + } + else + { + link.CrlStatus = CrlCheckStatus.Good; + link.CrlSource = CrlSource.Downloaded; + link.CrlSourceUrl = url; + } + + _logger.LogInformation("Online validation: resolved CRL from {Url}", url); + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Online validation: failed to download/parse CRL from {Url}", url); + } + } + + link.CrlStatus = CrlCheckStatus.CrlFetchFailed; + link.Problems.Add($"CRL download failed from all CDP URLs: {string.Join(", ", cdpUrls)}"); + } + + /// + /// Attempts to download the issuing CA certificate via the AIA extension (caIssuers method). + /// + private async Task<(CaCertificate entity, BcX509Certificate bcCert, string url)?> ResolveIssuerViaAiaAsync( + BcX509Certificate cert, CancellationToken ct) + { + try + { + var aiaExt = cert.GetExtensionValue(X509Extensions.AuthorityInfoAccess); + if (aiaExt == null) return null; + + var aiaObj = Asn1OctetString.GetInstance(aiaExt).GetOctets(); + var aiaSeq = Asn1Sequence.GetInstance(Asn1Object.FromByteArray(aiaObj)); + + var caIssuersUrls = new List(); + foreach (Asn1Encodable accessDesc in aiaSeq) + { + var seq = Asn1Sequence.GetInstance(accessDesc); + var oid = DerObjectIdentifier.GetInstance(seq[0]); + // 1.3.6.1.5.5.7.48.2 = caIssuers + if (oid.Id == "1.3.6.1.5.5.7.48.2") + { + var gn = GeneralName.GetInstance(seq[1]); + if (gn.TagNo == GeneralName.UniformResourceIdentifier) + { + var url = gn.Name.ToString(); + if (url != null && (url.StartsWith("http://") || url.StartsWith("https://"))) + caIssuersUrls.Add(url); + } + } + } + + foreach (var url in caIssuersUrls) + { + try + { + var client = _httpClientFactory.CreateClient("SigilCrl"); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + + var certBytes = await client.GetByteArrayAsync(url, cts.Token); + var parser = new X509CertificateParser(); + var issuerBc = parser.ReadCertificate(certBytes); + + if (issuerBc != null) + { + _logger.LogInformation("Resolved issuer via AIA from {Url}: {Subject}", + url, issuerBc.SubjectDN); + var placeholder = new CaCertificate { Name = issuerBc.SubjectDN.ToString() }; + return (placeholder, issuerBc, url); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download issuer cert from AIA URL {Url}", url); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse AIA extension"); + } + + return null; + } + + private void CheckRevocationInCrl(BcX509Certificate cert, ChainLink link, Crl storedCrl, CrlSource source) + { + var serialHex = cert.SerialNumber.ToString(16).ToUpperInvariant(); + var revocationEntry = storedCrl.Revocations + .FirstOrDefault(r => r.RevokedCertSerialNumber.Equals(serialHex, StringComparison.OrdinalIgnoreCase)); + + if (revocationEntry != null) + { + link.CrlStatus = CrlCheckStatus.Revoked; + link.CrlSource = source; + var reasonName = GetRevocationReasonName(revocationEntry.RevocationReason); + link.Problems.Add($"REVOKED on {revocationEntry.RevocationDate:yyyy-MM-dd} — {reasonName}"); + } + else + { + link.CrlStatus = CrlCheckStatus.Good; + link.CrlSource = source; + } + } + + private async Task DownloadCrlAsync(string url, CancellationToken ct) + { + try + { + var client = _httpClientFactory.CreateClient("SigilCrl"); + + var separator = url.Contains('?') ? '&' : '?'; + var bustUrl = $"{url}{separator}_t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + + _logger.LogDebug("Downloading CRL from {Url}", bustUrl); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + + var response = await client.GetAsync(bustUrl, cts.Token); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("CRL download returned {StatusCode} from {Url}", response.StatusCode, url); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(cts.Token); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CRL download failed from {Url}", url); + return null; + } + } + + private static List ExtractCdpUrls(BcX509Certificate cert) + { + var urls = new List(); + try + { + var cdpExt = cert.GetExtensionValue(X509Extensions.CrlDistributionPoints); + if (cdpExt == null) return urls; + + var cdpObj = Asn1OctetString.GetInstance(cdpExt).GetOctets(); + var cdpSeq = Asn1Sequence.GetInstance(Asn1Object.FromByteArray(cdpObj)); + + foreach (Asn1Encodable dpEncodable in cdpSeq) + { + var dp = DistributionPoint.GetInstance(dpEncodable); + var dpName = dp.DistributionPointName; + if (dpName?.PointType != DistributionPointName.FullName) continue; + + var generalNames = GeneralNames.GetInstance(dpName.Name); + foreach (var gn in generalNames.GetNames()) + { + if (gn.TagNo == GeneralName.UniformResourceIdentifier) + { + var uri = gn.Name.ToString(); + if (uri != null && (uri.StartsWith("http://") || uri.StartsWith("https://"))) + { + urls.Add(uri); + } + } + } + } + } + catch (Exception) + { + // Malformed CDP extension — ignore + } + + return urls; + } + + private static string GetRevocationReason(X509CrlEntry? entry) + { + if (entry == null) return "Unknown"; + try + { + var reasonExt = entry.GetExtensionValue(X509Extensions.ReasonCode); + if (reasonExt != null) + { + var asn1 = Org.BouncyCastle.X509.Extension.X509ExtensionUtilities.FromExtensionValue(reasonExt); + var code = DerEnumerated.GetInstance(asn1).IntValueExact; + return GetRevocationReasonName(code); + } + } + catch { } + return "Unspecified"; + } + + private static string GetRevocationReasonName(int code) => code switch + { + 0 => "Unspecified", + 1 => "Key Compromise", + 2 => "CA Compromise", + 3 => "Affiliation Changed", + 4 => "Superseded", + 5 => "Cessation of Operation", + 6 => "Certificate Hold", + 9 => "Privilege Withdrawn", + 10 => "AA Compromise", + _ => $"Unknown ({code})" + }; + + private static bool MatchesKeyIdentifiers(BcX509Certificate subject, BcX509Certificate issuer) + { + try + { + var akiValue = subject.GetExtensionValue(X509Extensions.AuthorityKeyIdentifier); + var skiValue = issuer.GetExtensionValue(X509Extensions.SubjectKeyIdentifier); + + if (akiValue == null || skiValue == null) return false; + + var aki = AuthorityKeyIdentifier.GetInstance( + Asn1OctetString.GetInstance(akiValue.GetOctets())); + var ski = SubjectKeyIdentifier.GetInstance( + Asn1OctetString.GetInstance(skiValue.GetOctets())); + + return aki.GetKeyIdentifier().SequenceEqual(ski.GetKeyIdentifier()); + } + catch + { + return false; + } + } +} + +public class ChainValidationResult +{ + public bool IsValid { get; init; } + public bool ReachedTrustAnchor { get; init; } + public string? Error { get; init; } + public List ChainLinks { get; init; } = new(); + + public static ChainValidationResult Failed(string error) => new() + { + IsValid = false, + ReachedTrustAnchor = false, + Error = error + }; +} + +public class ChainLink +{ + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public bool? SignatureValid { get; set; } + public bool IsTrustAnchor { get; set; } + public CrlCheckStatus CrlStatus { get; set; } = CrlCheckStatus.NotChecked; + public CrlSource CrlSource { get; set; } = CrlSource.None; + public string? CrlSourceUrl { get; set; } + public bool AiaResolved { get; set; } + public string? AiaResolvedUrl { get; set; } + public List Problems { get; set; } = new(); + + public bool HasProblems => Problems.Count > 0; + + public static ChainLink Problem(string name, BcX509Certificate cert, string problem) => new() + { + Name = name, + Subject = cert.SubjectDN.ToString(), + Issuer = cert.IssuerDN.ToString(), + Problems = { problem } + }; +} + +public enum CrlCheckStatus +{ + NotChecked, + Good, + Revoked, + NoCrlAvailable, + CrlExpired, + CrlFetchFailed, + IssuerNotFound +} + +public enum CrlSource +{ + None, + Stored, + Downloaded +} diff --git a/examples/CA/Sigil.Common/Services/CrlImportService.cs b/examples/CA/Sigil.Common/Services/CrlImportService.cs new file mode 100644 index 000000000..5095043ba --- /dev/null +++ b/examples/CA/Sigil.Common/Services/CrlImportService.cs @@ -0,0 +1,227 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; + +namespace Sigil.Common.Services; + +public class CrlImportService +{ + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public CrlImportService( + IDbContextFactory dbFactory, + ILogger logger) + { + _dbFactory = dbFactory; + _logger = logger; + } + + /// + /// Imports a CRL file, validates its signature against the issuing CA in the community, + /// and stores the CRL entity + revocation entries. + /// + public async Task ImportCrlAsync( + byte[] crlBytes, + string fileName, + int communityId, + CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + return await ImportCrlAsync(db, crlBytes, fileName, communityId, ct); + } + + /// + /// Imports a CRL using an existing DbContext (for use within a transaction/batch). + /// + public async Task ImportCrlAsync( + SigilDbContext db, + byte[] crlBytes, + string fileName, + int communityId, + CancellationToken ct = default) + { + var crlParser = new X509CrlParser(); + var crl = crlParser.ReadCrl(crlBytes); + var issuerDn = crl.IssuerDN.ToString(); + + // Find the issuing CA by matching issuer DN + var cas = await db.CaCertificates + .Where(ca => ca.CommunityId == communityId) + .ToListAsync(ct); + + CaCertificate? issuingCa = null; + + // Try DN matching first + foreach (var ca in cas) + { + if (DnMatch(ca.Subject, issuerDn)) + { + issuingCa = ca; + break; + } + } + + // Fallback: try signature verification against each CA + if (issuingCa == null) + { + var bcCertParser = new X509CertificateParser(); + foreach (var ca in cas) + { + try + { + var bcCaCert = bcCertParser.ReadCertificate( + System.Text.Encoding.UTF8.GetBytes(ca.X509CertificatePem)); + crl.Verify(bcCaCert.GetPublicKey()); + issuingCa = ca; + _logger.LogInformation("Matched CRL to CA '{Name}' via signature verification (DN match failed)", ca.Name); + break; + } + catch { } + } + } + + if (issuingCa == null) + { + return CrlImportResult.Failed($"No CA in this community matches CRL issuer: {issuerDn}"); + } + + // Extract CRL number + long crlNumber = 0; + var crlNumExt = crl.GetExtensionValue( + Org.BouncyCastle.Asn1.X509.X509Extensions.CrlNumber); + if (crlNumExt != null) + { + var asn1Num = X509ExtensionUtilities.FromExtensionValue(crlNumExt); + crlNumber = DerInteger.GetInstance(asn1Num).LongValueExact; + } + + // Validate CRL signature against the issuing CA + bool signatureValid = false; + try + { + var bcCertParser = new X509CertificateParser(); + var bcCaCert = bcCertParser.ReadCertificate( + System.Text.Encoding.UTF8.GetBytes(issuingCa.X509CertificatePem)); + crl.Verify(bcCaCert.GetPublicKey()); + signatureValid = true; + } + catch { } + + // Create the CRL entity + var crlEntity = new Crl + { + CaCertificateId = issuingCa.Id, + CrlNumber = crlNumber, + ThisUpdate = crl.ThisUpdate.ToUniversalTime(), + NextUpdate = crl.NextUpdate?.ToUniversalTime() ?? DateTime.MaxValue, + SignatureAlgorithm = crl.SigAlgName, + RawBytes = crlBytes, + FileName = fileName, + SignatureValid = signatureValid + }; + + db.Crls.Add(crlEntity); + await db.SaveChangesAsync(ct); + + // Import revocation entries + var revokedCerts = crl.GetRevokedCertificates(); + int revokedCount = 0; + + if (revokedCerts != null) + { + foreach (X509CrlEntry entry in revokedCerts) + { + int reason = 0; + try + { + var reasonExt = entry.GetExtensionValue( + Org.BouncyCastle.Asn1.X509.X509Extensions.ReasonCode); + if (reasonExt != null) + { + var asn1 = X509ExtensionUtilities.FromExtensionValue(reasonExt); + reason = DerEnumerated.GetInstance(asn1).IntValueExact; + } + } + catch { } + + db.CertificateRevocations.Add(new CertificateRevocation + { + CrlId = crlEntity.Id, + RevokedCertSerialNumber = entry.SerialNumber.ToString(16).ToUpperInvariant(), + RevocationDate = entry.RevocationDate.ToUniversalTime(), + RevocationReason = reason + }); + revokedCount++; + } + + await db.SaveChangesAsync(ct); + } + + _logger.LogInformation("Imported CRL #{CrlNumber} from {File}: {Count} revocations, signature {Valid}", + crlNumber, fileName, revokedCount, signatureValid ? "valid" : "INVALID"); + + return CrlImportResult.Success(crlNumber, revokedCount, signatureValid, + crl.NextUpdate?.ToUniversalTime(), issuingCa.Name); + } + + public static bool DnMatch(string dn1, string dn2) + { + var parts1 = ParseDnParts(dn1); + var parts2 = ParseDnParts(dn2); + return parts1.SetEquals(parts2); + } + + private static HashSet ParseDnParts(string dn) + { + var parts = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var part in dn.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("ST=", StringComparison.OrdinalIgnoreCase)) + trimmed = "S=" + trimmed[3..]; + if (trimmed.StartsWith("s=", StringComparison.OrdinalIgnoreCase)) + trimmed = "S=" + trimmed[2..]; + parts.Add(trimmed); + } + return parts; + } +} + +public record CrlImportResult +{ + public bool IsSuccess { get; init; } + public string? Error { get; init; } + public long CrlNumber { get; init; } + public int RevokedCount { get; init; } + public bool SignatureValid { get; init; } + public DateTime? NextUpdate { get; init; } + public string? IssuingCaName { get; init; } + + public static CrlImportResult Failed(string error) => new() { IsSuccess = false, Error = error }; + + public static CrlImportResult Success(long crlNumber, int revokedCount, bool signatureValid, + DateTime? nextUpdate, string issuingCaName) => new() + { + IsSuccess = true, + CrlNumber = crlNumber, + RevokedCount = revokedCount, + SignatureValid = signatureValid, + NextUpdate = nextUpdate, + IssuingCaName = issuingCaName + }; +} diff --git a/examples/CA/Sigil.Common/Services/Jobs/CrlAutoRenewalJob.cs b/examples/CA/Sigil.Common/Services/Jobs/CrlAutoRenewalJob.cs new file mode 100644 index 000000000..7911cea67 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Jobs/CrlAutoRenewalJob.cs @@ -0,0 +1,80 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Sigil.Common.Data; + +namespace Sigil.Common.Services.Jobs; + +/// +/// Scans all non-archived CA certificates and regenerates CRLs that are approaching +/// or past their NextUpdate. Intended to be invoked by Hangfire as a recurring job. +/// +public class CrlAutoRenewalJob +{ + private readonly IDbContextFactory _dbFactory; + private readonly CrlGenerationService _crlGenerationService; + private readonly ILogger _logger; + + public CrlAutoRenewalJob( + IDbContextFactory dbFactory, + CrlGenerationService crlGenerationService, + ILogger logger) + { + _dbFactory = dbFactory; + _crlGenerationService = crlGenerationService; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken ct) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var cas = await db.CaCertificates + .Where(ca => !ca.IsArchived) + .Include(ca => ca.Crls.Where(c => !c.IsArchived)) + .ToListAsync(ct); + + foreach (var ca in cas) + { + // Only generate CRLs for CAs that have a private key available + if (ca.EncryptedPfxBytes == null && string.IsNullOrEmpty(ca.StoreProviderHint)) + continue; + + var latestCrl = ca.Crls + .OrderByDescending(c => c.CrlNumber) + .FirstOrDefault(); + + // Renew if no CRL exists or NextUpdate is within 24 hours + var needsRenewal = latestCrl == null + || latestCrl.NextUpdate <= DateTime.UtcNow.AddHours(24); + + if (!needsRenewal) + continue; + + _logger.LogInformation("CRL renewal needed for CA '{CaName}' (ID {CaId})", ca.Name, ca.Id); + + var result = await _crlGenerationService.GenerateCrlAsync(ca.Id, ct: ct); + + if (result.IsSuccess) + { + _logger.LogInformation( + "Auto-renewed CRL for CA '{CaName}', CRL #{CrlNumber}, {RevokedCount} revocations, next update {NextUpdate}", + ca.Name, result.CrlNumber, result.RevokedCount, result.NextUpdate); + } + else + { + _logger.LogWarning( + "CRL auto-renewal failed for CA '{CaName}': {Error}", ca.Name, result.Error); + } + } + } +} diff --git a/examples/CA/Sigil.Common/Services/Jobs/CrlGenerationService.cs b/examples/CA/Sigil.Common/Services/Jobs/CrlGenerationService.cs new file mode 100644 index 000000000..881c51084 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Jobs/CrlGenerationService.cs @@ -0,0 +1,373 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using Sigil.Common.Services.Signing; +using X509Extensions = Org.BouncyCastle.Asn1.X509.X509Extensions; + +namespace Sigil.Common.Services.Jobs; + +public record CrlGenerationResult +{ + public bool IsSuccess { get; init; } + public string? Error { get; init; } + public int CrlEntityId { get; init; } + public long CrlNumber { get; init; } + public int RevokedCount { get; init; } + public DateTime NextUpdate { get; init; } + + public static CrlGenerationResult Failed(string error) => + new() { IsSuccess = false, Error = error }; + + public static CrlGenerationResult Success(int crlEntityId, long crlNumber, int revokedCount, DateTime nextUpdate) => + new() { IsSuccess = true, CrlEntityId = crlEntityId, CrlNumber = crlNumber, RevokedCount = revokedCount, NextUpdate = nextUpdate }; +} + +public class CrlGenerationService +{ + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + private readonly ISigningProvider _signingProvider; + + public CrlGenerationService( + IDbContextFactory dbFactory, + ILogger logger, + ISigningProvider signingProvider) + { + _dbFactory = dbFactory; + _logger = logger; + _signingProvider = signingProvider; + } + + /// + /// Generates a new CRL for the specified CA certificate. + /// Collects all revoked issued certificates and carries forward revocations from the latest existing CRL. + /// Signs with local PFX key or remote signing provider based on StoreProviderHint. + /// + public async Task GenerateCrlAsync( + int caCertificateId, + TimeSpan? validity = null, + CancellationToken ct = default) + { + validity ??= TimeSpan.FromDays(7); + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var ca = await db.CaCertificates + .Include(c => c.Crls.Where(crl => !crl.IsArchived)) + .FirstOrDefaultAsync(c => c.Id == caCertificateId, ct); + + if (ca == null) + return CrlGenerationResult.Failed($"CA certificate with ID {caCertificateId} not found."); + + // Collect revoked issued certificates for this CA + var revokedCerts = await db.IssuedCertificates + .Where(c => c.IssuingCaCertificateId == caCertificateId && c.IsRevoked && !c.IsArchived) + .Select(c => new + { + c.SerialNumber, + c.RevokedAt, + c.RevocationReason + }) + .ToListAsync(ct); + + // Collect revoked child CAs (intermediates signed by this CA) + var revokedChildCas = await db.CaCertificates + .Where(c => c.ParentId == caCertificateId && c.IsRevoked && !c.IsArchived) + .Select(c => new + { + c.SerialNumber, + c.RevokedAt, + c.RevocationReason + }) + .ToListAsync(ct); + + // Carry forward revocations from the latest existing CRL (for certs revoked via CRL import) + var latestCrl = ca.Crls + .OrderByDescending(c => c.CrlNumber) + .FirstOrDefault(); + + var carryForwardRevocations = new List(); + if (latestCrl != null) + { + carryForwardRevocations = await db.CertificateRevocations + .Where(r => r.CrlId == latestCrl.Id) + .ToListAsync(ct); + } + + // Determine next CRL number + long nextCrlNumber = (latestCrl?.CrlNumber ?? 0L) + 1; + + var now = DateTime.UtcNow; + var nextUpdate = now.Add(validity.Value); + + // Build unified revocation list (dedup by serial number) + var revocationEntries = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var cert in revokedCerts) + { + revocationEntries[cert.SerialNumber] = (cert.RevokedAt ?? now, cert.RevocationReason); + } + + foreach (var childCa in revokedChildCas) + { + revocationEntries[childCa.SerialNumber] = (childCa.RevokedAt ?? now, childCa.RevocationReason); + } + + foreach (var rev in carryForwardRevocations) + { + revocationEntries.TryAdd(rev.RevokedCertSerialNumber, (rev.RevocationDate, rev.RevocationReason)); + } + + byte[] crlBytes; + string signatureAlgorithm; + + try + { + if (!string.IsNullOrEmpty(ca.StoreProviderHint)) + { + // Remote signing path + (crlBytes, signatureAlgorithm) = await GenerateCrlRemoteAsync( + ca, revocationEntries, nextCrlNumber, now, nextUpdate, ct); + } + else + { + // Local signing path (PFX key) + (crlBytes, signatureAlgorithm) = GenerateCrlLocal( + ca, revocationEntries, nextCrlNumber, now, nextUpdate); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate CRL for CA '{CaName}' (ID {CaId})", ca.Name, ca.Id); + return CrlGenerationResult.Failed($"CRL generation failed: {ex.Message}"); + } + + // Archive previous active CRL + if (latestCrl != null) + { + latestCrl.IsArchived = true; + latestCrl.ArchivedAt = now; + } + + // Store new CRL + var crlEntity = new Crl + { + CaCertificateId = caCertificateId, + CrlNumber = nextCrlNumber, + ThisUpdate = now, + NextUpdate = nextUpdate, + SignatureAlgorithm = signatureAlgorithm, + RawBytes = crlBytes, + FileName = $"{ca.Name}-crl-{nextCrlNumber}.crl", + SignatureValid = true, + ImportedAt = now + }; + + db.Crls.Add(crlEntity); + await db.SaveChangesAsync(ct); + + // Add revocation entries to the new CRL + foreach (var (serial, (revokedAt, reason)) in revocationEntries) + { + db.CertificateRevocations.Add(new CertificateRevocation + { + CrlId = crlEntity.Id, + RevokedCertSerialNumber = serial, + RevocationDate = revokedAt, + RevocationReason = reason + }); + } + + if (revocationEntries.Count > 0) + await db.SaveChangesAsync(ct); + + _logger.LogInformation( + "Generated CRL #{CrlNumber} for CA '{CaName}' with {RevokedCount} revocations, next update {NextUpdate}", + nextCrlNumber, ca.Name, revocationEntries.Count, nextUpdate); + + // Publish CRL to configured endpoints using the CA's own name (non-fatal on failure) + var baseUrls = await db.CommunityBaseUrls + .Where(bu => bu.CommunityId == ca.CommunityId && bu.PublishingBasePath != null) + .ToListAsync(ct); + + foreach (var baseUrl in baseUrls) + { + if (string.IsNullOrEmpty(baseUrl.PublishingBasePath)) continue; + + try + { + var crlPath = Path.GetFullPath(Path.Combine(baseUrl.PublishingBasePath, "crls", $"{ca.Name}.crl")); + var crlDir = Path.GetDirectoryName(crlPath); + if (!string.IsNullOrEmpty(crlDir)) + Directory.CreateDirectory(crlDir); + + var tempPath = crlPath + ".tmp"; + await File.WriteAllBytesAsync(tempPath, crlBytes, ct); + File.Move(tempPath, crlPath, overwrite: true); + _logger.LogInformation("Published CRL to {Path} ({Bytes} bytes)", crlPath, crlBytes.Length); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish CRL for CA '{CaName}'", ca.Name); + } + } + + return CrlGenerationResult.Success(crlEntity.Id, nextCrlNumber, revocationEntries.Count, nextUpdate); + } + + private (byte[] CrlBytes, string SignatureAlgorithm) GenerateCrlLocal( + CaCertificate ca, + Dictionary revocationEntries, + long crlNumber, + DateTime thisUpdate, + DateTime nextUpdate) + { + if (ca.EncryptedPfxBytes == null) + throw new InvalidOperationException($"CA '{ca.Name}' has no PFX key available for local signing."); + + using var x509Ca = new X509Certificate2(ca.EncryptedPfxBytes, ca.PfxPassword, + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + + var bouncyCaCert = DotNetUtilities.FromX509Certificate(x509Ca); + + AsymmetricKeyParameter privateKey; + if (x509Ca.GetRSAPrivateKey() is RSA rsa) + { + privateKey = DotNetUtilities.GetKeyPair(rsa).Private; + } + else if (x509Ca.GetECDsaPrivateKey() is ECDsa ecdsa) + { + privateKey = DotNetUtilities.GetKeyPair(ecdsa).Private; + } + else + { + throw new InvalidOperationException($"Unsupported key algorithm for CA '{ca.Name}'."); + } + + var sigAlgName = GetLocalSignatureAlgorithmName(ca.KeyAlgorithm); + + var crlGen = new X509V2CrlGenerator(); + crlGen.SetIssuerDN(bouncyCaCert.SubjectDN); + crlGen.SetThisUpdate(thisUpdate); + crlGen.SetNextUpdate(nextUpdate); + + foreach (var (serial, (revokedAt, reason)) in revocationEntries) + { + crlGen.AddCrlEntry( + new BigInteger(serial, 16), + revokedAt, + reason); + } + + // CRL Number extension + crlGen.AddExtension(X509Extensions.CrlNumber, false, + new CrlNumber(new BigInteger(crlNumber.ToString()))); + + // Authority Key Identifier + crlGen.AddExtension(X509Extensions.AuthorityKeyIdentifier, false, + new AuthorityKeyIdentifierStructure(bouncyCaCert.GetPublicKey())); + + var crl = crlGen.Generate(new Asn1SignatureFactory(sigAlgName, privateKey)); + + return (crl.GetEncoded(), sigAlgName); + } + + private async Task<(byte[] CrlBytes, string SignatureAlgorithm)> GenerateCrlRemoteAsync( + CaCertificate ca, + Dictionary revocationEntries, + long crlNumber, + DateTime thisUpdate, + DateTime nextUpdate, + CancellationToken ct) + { + // Parse StoreProviderHint to get the key reference (e.g., "vault-transit:keyId") + var parts = ca.StoreProviderHint!.Split(':', 2); + if (parts.Length != 2) + throw new InvalidOperationException($"Invalid StoreProviderHint format: '{ca.StoreProviderHint}'"); + + var keyRef = new SigningKeyReference(parts[0], parts[1], ca.KeyAlgorithm, ca.KeySize); + + var hashAlgorithm = HashAlgorithmName.SHA256; + var sigAlgId = RemoteCertificateBuilder.GetSignatureAlgorithmIdentifier(ca.KeyAlgorithm, hashAlgorithm); + + // Build TBS CRL using BouncyCastle + var tbsGen = new V2TbsCertListGenerator(); + var issuerDn = new Org.BouncyCastle.Asn1.X509.X509Name(ca.Subject); + + tbsGen.SetIssuer(issuerDn); + tbsGen.SetThisUpdate(new Org.BouncyCastle.Asn1.X509.Time(thisUpdate)); + tbsGen.SetNextUpdate(new Org.BouncyCastle.Asn1.X509.Time(nextUpdate)); + tbsGen.SetSignature(sigAlgId); + + // Add revocation entries + foreach (var (serial, (revokedAt, reason)) in revocationEntries) + { + var serialNumber = new BigInteger(serial, 16); + var reasonExt = new X509Extensions( + new DerObjectIdentifier[] { X509Extensions.ReasonCode }, + new Org.BouncyCastle.Asn1.X509.X509Extension[] { + new(false, new DerOctetString(new CrlReason(reason).GetDerEncoded())) + }); + tbsGen.AddCrlEntry(new DerInteger(serialNumber), + new Org.BouncyCastle.Asn1.X509.Time(revokedAt), reasonExt); + } + + // Extensions + var extGen = new X509ExtensionsGenerator(); + + // CRL Number + extGen.AddExtension(X509Extensions.CrlNumber, false, + new CrlNumber(new BigInteger(crlNumber.ToString()))); + + // Authority Key Identifier — get public key from provider + var publicKey = await _signingProvider.GetPublicKeyAsync(keyRef, ct); + var bcPublicKey = PublicKeyFactory.CreateKey( + publicKey is RSA rsa + ? rsa.ExportSubjectPublicKeyInfo() + : ((ECDsa)publicKey).ExportSubjectPublicKeyInfo()); + + var akiValue = new AuthorityKeyIdentifier( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(bcPublicKey)); + extGen.AddExtension(X509Extensions.AuthorityKeyIdentifier, false, akiValue); + + tbsGen.SetExtensions(extGen.Generate()); + + var tbsCertList = tbsGen.GenerateTbsCertList(); + var tbsBytes = tbsCertList.GetDerEncoded(); + + // Sign asynchronously via the provider + var signature = await _signingProvider.SignDataAsync(tbsBytes, hashAlgorithm, keyRef, ct); + + // Assemble: SEQUENCE { tbsCertList, signatureAlgorithm, signatureValue } + var crlSeq = new DerSequence(tbsCertList, sigAlgId, new DerBitString(signature)); + var crlBytes = crlSeq.GetDerEncoded(); + + var sigAlgName = sigAlgId.Algorithm.Id; + return (crlBytes, sigAlgName); + } + + private static string GetLocalSignatureAlgorithmName(string keyAlgorithm) => + keyAlgorithm.Equals("RSA", StringComparison.OrdinalIgnoreCase) + ? "SHA256WithRSAEncryption" + : "SHA256WithECDSA"; +} diff --git a/examples/CA/Sigil.Common/Services/Jobs/IRecurringJobManager.cs b/examples/CA/Sigil.Common/Services/Jobs/IRecurringJobManager.cs new file mode 100644 index 000000000..b14018cfe --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Jobs/IRecurringJobManager.cs @@ -0,0 +1,25 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Services.Jobs; + +public record RecurringJobInfo(string JobId, string CronExpression, string? LastExecution, string? NextExecution); + +/// +/// Abstraction over Hangfire's recurring job management so the UI layer +/// can read and update job schedules without depending on Hangfire directly. +/// +public interface IRecurringJobScheduler +{ + List GetRecurringJobs(); + void UpdateCronExpression(string jobId, string cronExpression); + void TriggerJob(string jobId); + void RemoveJob(string jobId); +} diff --git a/examples/CA/Sigil.Common/Services/Publishing/IPublishingProvider.cs b/examples/CA/Sigil.Common/Services/Publishing/IPublishingProvider.cs new file mode 100644 index 000000000..7d758fa4e --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Publishing/IPublishingProvider.cs @@ -0,0 +1,35 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Services.Publishing; + +/// +/// Abstraction for publishing CRLs and certificates to external locations. +/// Implementations handle specific storage backends (file system, GCS, etc.). +/// The provider receives the full target URL and extracts the path to determine +/// where to write the content. +/// +public interface IPublishingProvider +{ + /// + /// Provider type identifier for diagnostics/logging (e.g., "filesystem", "gcp"). + /// + string ProviderType { get; } + + /// + /// Publishes a DER-encoded CRL to the location derived from the target URL. + /// + Task PublishCrlAsync(Uri targetUrl, byte[] crlBytes, CancellationToken ct = default); + + /// + /// Publishes a certificate (DER or PEM) to the location derived from the target URL. + /// + Task PublishCertificateAsync(Uri targetUrl, byte[] certBytes, CancellationToken ct = default); +} diff --git a/examples/CA/Sigil.Common/Services/Publishing/PublishingCoordinator.cs b/examples/CA/Sigil.Common/Services/Publishing/PublishingCoordinator.cs new file mode 100644 index 000000000..d6c0d21bf --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Publishing/PublishingCoordinator.cs @@ -0,0 +1,100 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Logging; + +namespace Sigil.Common.Services.Publishing; + +/// +/// Routes CRL and certificate publishing requests to the appropriate provider +/// based on the domain (Authority) portion of the target URL. +/// Multiple providers can be active simultaneously for different domains. +/// +public class PublishingCoordinator +{ + private readonly Dictionary _providersByDomain; + private readonly ILogger _logger; + + public PublishingCoordinator( + Dictionary providersByDomain, + ILogger logger) + { + _providersByDomain = providersByDomain; + _logger = logger; + } + + /// + /// Publishes a CRL to the location specified by the CDP URL. + /// The URL domain is used to select the publishing provider. + /// Publishing failures are logged but do not throw. + /// + public async Task PublishCrlAsync(string? cdpUrl, byte[] crlBytes, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(cdpUrl)) return; + + if (!Uri.TryCreate(cdpUrl, UriKind.Absolute, out var uri)) + { + _logger.LogWarning("Invalid CDP URL: '{CdpUrl}'", cdpUrl); + return; + } + + if (!_providersByDomain.TryGetValue(uri.Authority, out var provider)) + { + _logger.LogWarning( + "No publishing provider configured for domain '{Domain}'. Configured domains: [{Domains}]", + uri.Authority, string.Join(", ", _providersByDomain.Keys)); + return; + } + + try + { + await provider.PublishCrlAsync(uri, crlBytes, ct); + _logger.LogInformation("Published CRL to {Url} via {ProviderType} provider", cdpUrl, provider.ProviderType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish CRL to {Url} via {ProviderType} provider", cdpUrl, provider.ProviderType); + } + } + + /// + /// Publishes a certificate to the location specified by the AIA URL. + /// The URL domain is used to select the publishing provider. + /// Publishing failures are logged but do not throw. + /// + public async Task PublishCertificateAsync(string? aiaUrl, byte[] certBytes, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(aiaUrl)) return; + + if (!Uri.TryCreate(aiaUrl, UriKind.Absolute, out var uri)) + { + _logger.LogWarning("Invalid AIA URL: '{AiaUrl}'", aiaUrl); + return; + } + + if (!_providersByDomain.TryGetValue(uri.Authority, out var provider)) + { + _logger.LogWarning( + "No publishing provider configured for domain '{Domain}'. Configured domains: [{Domains}]", + uri.Authority, string.Join(", ", _providersByDomain.Keys)); + return; + } + + try + { + await provider.PublishCertificateAsync(uri, certBytes, ct); + _logger.LogInformation("Published certificate to {Url} via {ProviderType} provider", aiaUrl, provider.ProviderType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish certificate to {Url} via {ProviderType} provider", aiaUrl, provider.ProviderType); + } + } +} diff --git a/examples/CA/Sigil.Common/Services/Publishing/PublishingOptions.cs b/examples/CA/Sigil.Common/Services/Publishing/PublishingOptions.cs new file mode 100644 index 000000000..8741fe4b1 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Publishing/PublishingOptions.cs @@ -0,0 +1,47 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Services.Publishing; + +/// +/// Configuration for the publishing system. Each entry in +/// maps a URL domain (with optional port) to a specific provider configuration. +/// When a CRL or certificate is published, the domain portion of the CDP/AIA URL +/// is used to look up the matching provider. +/// +public class PublishingOptions +{ + public List Providers { get; set; } = new(); +} + +public class PublishingProviderConfig +{ + /// + /// The URL domain (with optional port) this provider handles, + /// e.g. "host.docker.internal:5033" or "storage.googleapis.com". + /// + public string Domain { get; set; } = string.Empty; + + /// + /// Provider type: "filesystem" or "gcp". + /// + public string Type { get; set; } = string.Empty; + + /// + /// Base directory for the filesystem provider. + /// The URL path is appended to this to determine the file location. + /// + public string? BaseDirectory { get; set; } + + /// + /// GCS bucket name for the GCP provider. + /// + public string? BucketName { get; set; } +} diff --git a/examples/CA/Sigil.Common/Services/Signing/ISigningProvider.cs b/examples/CA/Sigil.Common/Services/Signing/ISigningProvider.cs new file mode 100644 index 000000000..9b2276724 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Signing/ISigningProvider.cs @@ -0,0 +1,49 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; + +namespace Sigil.Common.Services.Signing; + +/// +/// Abstraction for certificate signing operations. Implementations may use local keys (PFX) +/// or remote signing services (Vault Transit, Cloud KMS) where private keys never leave +/// the provider boundary. +/// +public interface ISigningProvider +{ + /// + /// The provider name, e.g. "local" or "vault-transit". + /// + string ProviderName { get; } + + /// + /// Signs data using the referenced key. + /// For local: signs with the in-memory private key. + /// For Vault Transit: calls the Transit sign API. + /// + Task SignDataAsync(byte[] data, HashAlgorithmName hashAlgorithm, + SigningKeyReference keyRef, CancellationToken ct = default); + + /// + /// Gets the public key for the referenced key. + /// Used to build CertificateRequest with the correct public key before signing. + /// + Task GetPublicKeyAsync(SigningKeyReference keyRef, + CancellationToken ct = default); + + /// + /// Generates a new key pair and returns a reference to it. + /// For local: creates RSA/ECDSA in memory. + /// For Vault: creates a new Transit key. + /// + Task GenerateKeyAsync(string keyAlgorithm, int keySize, + string? ecdsaCurve = null, CancellationToken ct = default); +} diff --git a/examples/CA/Sigil.Common/Services/Signing/LocalSigningProvider.cs b/examples/CA/Sigil.Common/Services/Signing/LocalSigningProvider.cs new file mode 100644 index 000000000..54674936b --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Signing/LocalSigningProvider.cs @@ -0,0 +1,113 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace Sigil.Common.Services.Signing; + +/// +/// Local signing provider that generates and holds keys in memory. +/// This is the default provider — same behavior as the original CertificateIssuanceService. +/// +public sealed class LocalSigningProvider : ISigningProvider, IDisposable +{ + public string ProviderName => "local"; + + private readonly ConcurrentDictionary _keys = new(); + + public Task GenerateKeyAsync( + string keyAlgorithm, int keySize, string? ecdsaCurve = null, + CancellationToken ct = default) + { + var id = Guid.NewGuid().ToString("N"); + AsymmetricAlgorithm key; + + if (keyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + var curve = ecdsaCurve?.ToLowerInvariant() switch + { + "nistp256" => ECCurve.NamedCurves.nistP256, + "nistp384" => ECCurve.NamedCurves.nistP384, + "nistp521" => ECCurve.NamedCurves.nistP521, + _ => ECCurve.NamedCurves.nistP384 + }; + key = ECDsa.Create(curve); + keySize = ((ECDsa)key).KeySize; + } + else + { + key = RSA.Create(keySize); + } + + _keys[id] = key; + + return Task.FromResult(new SigningKeyReference("local", id, keyAlgorithm, keySize)); + } + + public Task GetPublicKeyAsync( + SigningKeyReference keyRef, CancellationToken ct = default) + { + if (!_keys.TryGetValue(keyRef.KeyIdentifier, out var key)) + throw new InvalidOperationException($"Local key '{keyRef.KeyIdentifier}' not found."); + + return Task.FromResult(key); + } + + public Task SignDataAsync( + byte[] data, HashAlgorithmName hashAlgorithm, + SigningKeyReference keyRef, CancellationToken ct = default) + { + if (!_keys.TryGetValue(keyRef.KeyIdentifier, out var key)) + throw new InvalidOperationException($"Local key '{keyRef.KeyIdentifier}' not found."); + + byte[] signature; + if (key is ECDsa ecdsa) + { + signature = ecdsa.SignData(data, hashAlgorithm); + } + else if (key is RSA rsa) + { + signature = rsa.SignData(data, hashAlgorithm, RSASignaturePadding.Pkcs1); + } + else + { + throw new NotSupportedException($"Unsupported key type: {key.GetType().Name}"); + } + + return Task.FromResult(signature); + } + + /// + /// Gets the raw asymmetric algorithm for direct use by CertificateRequest (local path only). + /// + internal AsymmetricAlgorithm GetKeyDirect(string keyIdentifier) + { + if (!_keys.TryGetValue(keyIdentifier, out var key)) + throw new InvalidOperationException($"Local key '{keyIdentifier}' not found."); + return key; + } + + /// + /// Removes a key from the in-memory store after certificate generation is complete. + /// + internal void ReleaseKey(string keyIdentifier) + { + if (_keys.TryRemove(keyIdentifier, out var key)) + key.Dispose(); + } + + public void Dispose() + { + foreach (var key in _keys.Values) + key.Dispose(); + _keys.Clear(); + } +} diff --git a/examples/CA/Sigil.Common/Services/Signing/RemoteCertificateBuilder.cs b/examples/CA/Sigil.Common/Services/Signing/RemoteCertificateBuilder.cs new file mode 100644 index 000000000..e6098c9fd --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Signing/RemoteCertificateBuilder.cs @@ -0,0 +1,204 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.X509; +using X509Extension = System.Security.Cryptography.X509Certificates.X509Extension; + +namespace Sigil.Common.Services.Signing; + +/// +/// Builds X.509 certificates using BouncyCastle to compute TBS (to-be-signed) bytes, +/// then signs asynchronously via ISigningProvider, and assembles the final DER certificate. +/// This avoids the sync-over-async deadlock that would occur with BouncyCastle's +/// synchronous ISignatureFactory in Blazor Server's sync context. +/// +public static class RemoteCertificateBuilder +{ + /// + /// Creates a self-signed root CA certificate using remote signing. + /// + public static async Task CreateSelfSignedAsync( + ISigningProvider provider, + SigningKeyReference keyRef, + string subjectDn, + DateTimeOffset notBefore, + DateTimeOffset notAfter, + X509Extension[] extensions, + HashAlgorithmName hashAlgorithm, + CancellationToken ct = default) + { + var publicKey = await provider.GetPublicKeyAsync(keyRef, ct); + var bcPublicKey = ConvertToBouncyCastlePublicKey(publicKey); + + var tbsGen = new V3TbsCertificateGenerator(); + var subject = new X509Name(subjectDn); + var serialNumber = GenerateSerialNumber(); + var sigAlgId = GetSignatureAlgorithmIdentifier(keyRef.KeyAlgorithm, hashAlgorithm); + + tbsGen.SetSerialNumber(new DerInteger(serialNumber)); + tbsGen.SetIssuer(subject); + tbsGen.SetSubject(subject); + tbsGen.SetStartDate(new Org.BouncyCastle.Asn1.X509.Time(notBefore.UtcDateTime)); + tbsGen.SetEndDate(new Org.BouncyCastle.Asn1.X509.Time(notAfter.UtcDateTime)); + tbsGen.SetSubjectPublicKeyInfo( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(bcPublicKey)); + tbsGen.SetSignature(sigAlgId); + + // Add extensions + var extGen = new X509ExtensionsGenerator(); + foreach (var ext in extensions) + { + var oid = new DerObjectIdentifier(ext.Oid!.Value!); + extGen.AddExtension(oid, ext.Critical, + Asn1Object.FromByteArray(ext.RawData)); + } + tbsGen.SetExtensions(extGen.Generate()); + + // Generate TBS bytes + var tbsCert = tbsGen.GenerateTbsCertificate(); + var tbsBytes = tbsCert.GetDerEncoded(); + + // Sign asynchronously via the provider (no sync-over-async deadlock) + var signature = await provider.SignDataAsync(tbsBytes, hashAlgorithm, keyRef, ct); + + // Assemble the final X.509 certificate: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue } + var certDer = AssembleCertificateDer(tbsCert, sigAlgId, signature); + + return new X509Certificate2(certDer); + } + + /// + /// Creates a CA-signed certificate (intermediate CA or end-entity) using remote signing. + /// + public static async Task CreateSignedAsync( + ISigningProvider provider, + SigningKeyReference issuerKeyRef, + X509Certificate2 issuerCert, + AsymmetricAlgorithm subjectPublicKey, + string subjectDn, + DateTimeOffset notBefore, + DateTimeOffset notAfter, + X509Extension[] extensions, + HashAlgorithmName hashAlgorithm, + CancellationToken ct = default) + { + var bcSubjectPublicKey = ConvertToBouncyCastlePublicKey(subjectPublicKey); + + var tbsGen = new V3TbsCertificateGenerator(); + var issuerDn = new X509Name(issuerCert.Subject); + var subject = new X509Name(subjectDn); + var serialNumber = GenerateSerialNumber(); + var sigAlgId = GetSignatureAlgorithmIdentifier(issuerKeyRef.KeyAlgorithm, hashAlgorithm); + + tbsGen.SetSerialNumber(new DerInteger(serialNumber)); + tbsGen.SetIssuer(issuerDn); + tbsGen.SetSubject(subject); + tbsGen.SetStartDate(new Org.BouncyCastle.Asn1.X509.Time(notBefore.UtcDateTime)); + tbsGen.SetEndDate(new Org.BouncyCastle.Asn1.X509.Time(notAfter.UtcDateTime)); + tbsGen.SetSubjectPublicKeyInfo( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(bcSubjectPublicKey)); + tbsGen.SetSignature(sigAlgId); + + // Add extensions + var extGen = new X509ExtensionsGenerator(); + foreach (var ext in extensions) + { + var oid = new DerObjectIdentifier(ext.Oid!.Value!); + extGen.AddExtension(oid, ext.Critical, + Asn1Object.FromByteArray(ext.RawData)); + } + tbsGen.SetExtensions(extGen.Generate()); + + // Generate TBS bytes + var tbsCert = tbsGen.GenerateTbsCertificate(); + var tbsBytes = tbsCert.GetDerEncoded(); + + // Sign asynchronously via the provider + var signature = await provider.SignDataAsync(tbsBytes, hashAlgorithm, issuerKeyRef, ct); + + // Assemble the final certificate + var certDer = AssembleCertificateDer(tbsCert, sigAlgId, signature); + + return new X509Certificate2(certDer); + } + + /// + /// Assembles a DER-encoded X.509 certificate from TBS, algorithm, and signature. + /// Structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue BIT STRING } + /// + private static byte[] AssembleCertificateDer( + TbsCertificateStructure tbsCert, + AlgorithmIdentifier sigAlgId, + byte[] signatureBytes) + { + var certSeq = new DerSequence( + tbsCert, + sigAlgId, + new DerBitString(signatureBytes)); + + return certSeq.GetDerEncoded(); + } + + internal static AlgorithmIdentifier GetSignatureAlgorithmIdentifier( + string keyAlgorithm, HashAlgorithmName hashAlgorithm) + { + if (keyAlgorithm.Equals("RSA", StringComparison.OrdinalIgnoreCase)) + { + var oid = hashAlgorithm.Name switch + { + "SHA384" => Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Sha384WithRsaEncryption, + "SHA512" => Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Sha512WithRsaEncryption, + _ => Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Sha256WithRsaEncryption + }; + return new AlgorithmIdentifier(oid, DerNull.Instance); + } + else // ECDSA + { + var oid = hashAlgorithm.Name switch + { + "SHA384" => Org.BouncyCastle.Asn1.X9.X9ObjectIdentifiers.ECDsaWithSha384, + "SHA512" => Org.BouncyCastle.Asn1.X9.X9ObjectIdentifiers.ECDsaWithSha512, + _ => Org.BouncyCastle.Asn1.X9.X9ObjectIdentifiers.ECDsaWithSha256 + }; + return new AlgorithmIdentifier(oid); + } + } + + private static AsymmetricKeyParameter ConvertToBouncyCastlePublicKey(AsymmetricAlgorithm key) + { + byte[] spki; + if (key is RSA rsa) + { + spki = rsa.ExportSubjectPublicKeyInfo(); + } + else if (key is ECDsa ecdsa) + { + spki = ecdsa.ExportSubjectPublicKeyInfo(); + } + else + { + throw new NotSupportedException($"Unsupported key type: {key.GetType().Name}"); + } + + return Org.BouncyCastle.Security.PublicKeyFactory.CreateKey(spki); + } + + private static Org.BouncyCastle.Math.BigInteger GenerateSerialNumber() + { + var serialBytes = RandomNumberGenerator.GetBytes(16); + serialBytes[0] &= 0x7F; // Ensure positive + return new Org.BouncyCastle.Math.BigInteger(1, serialBytes); + } +} diff --git a/examples/CA/Sigil.Common/Services/Signing/SigningKeyReference.cs b/examples/CA/Sigil.Common/Services/Signing/SigningKeyReference.cs new file mode 100644 index 000000000..161f2c2b7 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Signing/SigningKeyReference.cs @@ -0,0 +1,27 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Services.Signing; + +/// +/// Identifies a signing key across provider boundaries. +/// +/// Provider name: "local" or "vault-transit" +/// +/// For local: a GUID referencing an in-memory key holder. +/// For Vault Transit: the Transit key name (e.g. "sigil-rsa-4096"). +/// +/// "RSA" or "ECDSA" +/// Key size in bits (2048, 4096, etc.) +public record SigningKeyReference( + string Provider, + string KeyIdentifier, + string KeyAlgorithm, + int KeySize); diff --git a/examples/CA/Sigil.Common/Services/Signing/SigningProviderOptions.cs b/examples/CA/Sigil.Common/Services/Signing/SigningProviderOptions.cs new file mode 100644 index 000000000..944687430 --- /dev/null +++ b/examples/CA/Sigil.Common/Services/Signing/SigningProviderOptions.cs @@ -0,0 +1,22 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.Services.Signing; + +/// +/// Configuration for signing provider selection. +/// +public class SigningProviderOptions +{ + /// + /// The active signing provider: "local", "vault-transit", or "gcp-kms". + /// + public string Provider { get; set; } = "local"; +} diff --git a/examples/CA/Sigil.Common/Sigil.Common.csproj b/examples/CA/Sigil.Common/Sigil.Common.csproj new file mode 100644 index 000000000..33ba0a662 --- /dev/null +++ b/examples/CA/Sigil.Common/Sigil.Common.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/examples/CA/Sigil.Common/ViewModels/CertificateChainNodeViewModel.cs b/examples/CA/Sigil.Common/ViewModels/CertificateChainNodeViewModel.cs new file mode 100644 index 000000000..196106075 --- /dev/null +++ b/examples/CA/Sigil.Common/ViewModels/CertificateChainNodeViewModel.cs @@ -0,0 +1,38 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.ViewModels; + +public class CertificateChainNodeViewModel +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Thumbprint { get; set; } = string.Empty; + public DateTime NotAfter { get; set; } + public CertificateStatus Status { get; set; } + + /// + /// "RootCA", "IntermediateCA", "EndEntity", or "CRL". + /// + public string CertificateRole { get; set; } = string.Empty; + + /// + /// Entity type for navigation: "CaCertificate", "IssuedCertificate", or "Crl". + /// + public string EntityType { get; set; } = string.Empty; + + /// + /// Key storage type: "local", "vault-transit", or null (no private key / CRL). + /// + public string? KeyStorage { get; set; } + + public List Children { get; set; } = new(); +} diff --git a/examples/CA/Sigil.Common/ViewModels/CertificateIssuanceRequest.cs b/examples/CA/Sigil.Common/ViewModels/CertificateIssuanceRequest.cs new file mode 100644 index 000000000..e10e39235 --- /dev/null +++ b/examples/CA/Sigil.Common/ViewModels/CertificateIssuanceRequest.cs @@ -0,0 +1,157 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.ViewModels; + +/// +/// Request DTO for certificate issuance. Designed for use by UI, CLI, or API consumers. +/// +public class CertificateIssuanceRequest +{ + /// + /// The ID of the issuing CA certificate. Null for self-signed root CA generation. + /// + public int? IssuingCaCertificateId { get; set; } + + /// + /// The template that defines extensions, key parameters, and defaults. + /// + public int TemplateId { get; set; } + + /// + /// The community this certificate belongs to. + /// + public int CommunityId { get; set; } + + /// + /// The X.500 distinguished name for the certificate subject. + /// Example: "CN=My Cert, O=My Org, C=US" + /// + public string SubjectDn { get; set; } = string.Empty; + + /// + /// A friendly display name for the certificate in the UI. + /// + public string CertificateName { get; set; } = string.Empty; + + /// + /// Subject Alternative Name entries to include in the certificate. + /// + public List SubjectAltNames { get; set; } = []; + + /// + /// CRL Distribution Point URLs. Only used when the template has IncludeCdp enabled. + /// + public List CdpUrls { get; set; } = []; + + /// + /// Authority Information Access URLs. Only used when the template has IncludeAia enabled. + /// + public List AiaUrls { get; set; } = []; + + /// + /// Certificate validity start. Defaults to UtcNow if not specified. + /// + public DateTimeOffset? NotBefore { get; set; } + + /// + /// Certificate validity end. Defaults to NotBefore + template.ValidityDays if not specified. + /// + public DateTimeOffset? NotAfter { get; set; } + + /// + /// Password for the exported PFX (PKCS#12) file containing the private key. + /// Not required when using a remote signing provider (e.g. Vault Transit). + /// + public string PfxPassword { get; set; } = string.Empty; + + /// + /// Override for the signing provider to use for this request. + /// "local" = PFX-based local signing (default). + /// "vault-transit" = HashiCorp Vault Transit remote signing. + /// Null = use the globally configured provider. + /// + public string? SigningProviderOverride { get; set; } +} + +/// +/// Request DTO for re-signing an existing certificate with the same key pair. +/// Creates a new certificate with a new serial number and validity period, +/// but preserves the original private key and SKI so downstream chains remain valid. +/// +public class CertificateResignRequest +{ + /// + /// The database ID of the existing certificate to re-sign. + /// Must be a CaCertificate (Root or Intermediate) with a private key. + /// + public int ExistingCertificateId { get; set; } + + /// + /// "CaCertificate" or "IssuedCertificate". + /// + public string EntityType { get; set; } = "CaCertificate"; + + /// + /// New validity start. Defaults to UtcNow if not specified. + /// + public DateTimeOffset? NotBefore { get; set; } + + /// + /// New validity end. Defaults to NotBefore + original validity duration if not specified. + /// + public DateTimeOffset? NotAfter { get; set; } + + /// + /// Password for the exported PFX containing the (same) private key. + /// + public string PfxPassword { get; set; } = string.Empty; +} + +/// +/// A single Subject Alternative Name entry. +/// +public record SanEntry(SanType Type, string Value); + +/// +/// The type of Subject Alternative Name. +/// +public enum SanType +{ + Uri, + Dns, + Email, + IpAddress +} + +/// +/// Result DTO returned after certificate issuance. +/// +public class CertificateIssuanceResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + + /// + /// The database ID of the newly created certificate entity. + /// + public int? EntityId { get; set; } + + /// + /// "CaCertificate" for Root/Intermediate CAs, "IssuedCertificate" for end-entity certs. + /// + public string? EntityType { get; set; } + + public string? Thumbprint { get; set; } + public string? SerialNumber { get; set; } + + public static CertificateIssuanceResult Failure(string error) => + new() { Success = false, Error = error }; +} diff --git a/examples/CA/Sigil.Common/ViewModels/CertificateViewModel.cs b/examples/CA/Sigil.Common/ViewModels/CertificateViewModel.cs new file mode 100644 index 000000000..74d842395 --- /dev/null +++ b/examples/CA/Sigil.Common/ViewModels/CertificateViewModel.cs @@ -0,0 +1,56 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Sigil.Common.Data.Entities; + +namespace Sigil.Common.ViewModels; + +public class CertificateViewModel +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string? SubjectAltNames { get; set; } + public string Thumbprint { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public string KeyAlgorithm { get; set; } = "RSA"; + public int KeySize { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public bool IsRevoked { get; set; } + public bool Enabled { get; set; } + public string? CrlDistributionPoint { get; set; } + public string? AuthorityInfoAccessUri { get; set; } + + /// + /// "RootCA", "IntermediateCA", or "EndEntity". + /// + public string CertificateRole { get; set; } = string.Empty; + + public CertificateStatus Status + { + get + { + if (IsRevoked) return CertificateStatus.Revoked; + if (DateTime.UtcNow > NotAfter) return CertificateStatus.Expired; + if (DateTime.UtcNow > NotAfter.AddDays(-30)) return CertificateStatus.Expiring; + return CertificateStatus.Valid; + } + } +} + +public enum CertificateStatus +{ + Valid, + Expiring, + Expired, + Revoked, + Untrusted +} diff --git a/examples/CA/Sigil.Common/ViewModels/CommunityViewModel.cs b/examples/CA/Sigil.Common/ViewModels/CommunityViewModel.cs new file mode 100644 index 000000000..2f65c4e6c --- /dev/null +++ b/examples/CA/Sigil.Common/ViewModels/CommunityViewModel.cs @@ -0,0 +1,30 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.ViewModels; + +public class CommunityViewModel +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public List BaseUrls { get; set; } = new(); + public string BaseUrlsDisplay => string.Join("; ", BaseUrls.Select(b => b.Url)); + public bool Enabled { get; set; } = true; + public int RootCaCount { get; set; } + public int TotalCertCount { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class BaseUrlViewModel +{ + public string Url { get; set; } = string.Empty; + public string? PublishingBasePath { get; set; } +} diff --git a/examples/CA/Sigil.Common/ViewModels/CrlViewModel.cs b/examples/CA/Sigil.Common/ViewModels/CrlViewModel.cs new file mode 100644 index 000000000..091ecdcfd --- /dev/null +++ b/examples/CA/Sigil.Common/ViewModels/CrlViewModel.cs @@ -0,0 +1,69 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.ViewModels; + +public class CrlViewModel +{ + public int Id { get; set; } + public int CaCertificateId { get; set; } + public string CaName { get; set; } = string.Empty; + public long CrlNumber { get; set; } + public DateTime ThisUpdate { get; set; } + public DateTime NextUpdate { get; set; } + public string SignatureAlgorithm { get; set; } = string.Empty; + public bool SignatureValid { get; set; } + public string? FileName { get; set; } + public string Thumbprint { get; set; } = string.Empty; + public string? AuthorityKeyIdentifier { get; set; } + public int RevokedCount { get; set; } + public DateTime ImportedAt { get; set; } + + public CrlTimeStatus TimeStatus + { + get + { + if (DateTime.UtcNow > NextUpdate) return CrlTimeStatus.Expired; + if (DateTime.UtcNow > NextUpdate.AddDays(-7)) return CrlTimeStatus.ExpiringSoon; + return CrlTimeStatus.Valid; + } + } + + public List RevokedCertificates { get; set; } = new(); +} + +public enum CrlTimeStatus +{ + Valid, + ExpiringSoon, + Expired +} + +public class RevokedCertEntry +{ + public string SerialNumber { get; set; } = string.Empty; + public string? Thumbprint { get; set; } + public DateTime RevocationDate { get; set; } + public int ReasonCode { get; set; } + + public string ReasonName => ReasonCode switch + { + 0 => "Unspecified", + 1 => "Key Compromise", + 2 => "CA Compromise", + 3 => "Affiliation Changed", + 4 => "Superseded", + 5 => "Cessation of Operation", + 6 => "Certificate Hold", + 9 => "Privilege Withdrawn", + 10 => "AA Compromise", + _ => $"Unknown ({ReasonCode})" + }; +} diff --git a/examples/CA/Sigil.Common/ViewModels/ImportPreviewViewModel.cs b/examples/CA/Sigil.Common/ViewModels/ImportPreviewViewModel.cs new file mode 100644 index 000000000..be8e0b6b9 --- /dev/null +++ b/examples/CA/Sigil.Common/ViewModels/ImportPreviewViewModel.cs @@ -0,0 +1,23 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Common.ViewModels; + +public class ImportPreviewViewModel +{ + public string CommunityName { get; set; } = string.Empty; + public string DirectoryPath { get; set; } = string.Empty; + public int RootCaCount { get; set; } + public int IntermediateCount { get; set; } + public int IssuedCertCount { get; set; } + public int CrlCount { get; set; } + public List Errors { get; set; } = new(); + public bool IsValid => Errors.Count == 0; +} diff --git a/examples/CA/Sigil.FileSystem/FileSystemPublishingProvider.cs b/examples/CA/Sigil.FileSystem/FileSystemPublishingProvider.cs new file mode 100644 index 000000000..36ae4d47c --- /dev/null +++ b/examples/CA/Sigil.FileSystem/FileSystemPublishingProvider.cs @@ -0,0 +1,65 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.Extensions.Logging; +using Sigil.Common.Services.Publishing; + +namespace Sigil.FileSystem; + +/// +/// Publishes CRLs and certificates to the local file system. +/// The URL path is extracted and combined with the configured base directory +/// to determine the file location. Directories are created as needed. +/// +public sealed class FileSystemPublishingProvider : IPublishingProvider +{ + public string ProviderType => "filesystem"; + + private readonly string _baseDirectory; + private readonly ILogger _logger; + + public FileSystemPublishingProvider(string baseDirectory, ILogger logger) + { + _baseDirectory = Path.GetFullPath(baseDirectory); + _logger = logger; + } + + public async Task PublishCrlAsync(Uri targetUrl, byte[] crlBytes, CancellationToken ct = default) + { + var fullPath = ResolvePath(targetUrl); + await WriteFileAsync(fullPath, crlBytes, ct); + _logger.LogInformation("Published CRL to {Path} ({Bytes} bytes)", fullPath, crlBytes.Length); + } + + public async Task PublishCertificateAsync(Uri targetUrl, byte[] certBytes, CancellationToken ct = default) + { + var fullPath = ResolvePath(targetUrl); + await WriteFileAsync(fullPath, certBytes, ct); + _logger.LogInformation("Published certificate to {Path} ({Bytes} bytes)", fullPath, certBytes.Length); + } + + private string ResolvePath(Uri targetUrl) + { + var relativePath = targetUrl.AbsolutePath.TrimStart('/'); + return Path.GetFullPath(Path.Combine(_baseDirectory, relativePath)); + } + + private static async Task WriteFileAsync(string fullPath, byte[] bytes, CancellationToken ct) + { + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + // Write to temp file first, then move for atomicity + var tempPath = fullPath + ".tmp"; + await File.WriteAllBytesAsync(tempPath, bytes, ct); + File.Move(tempPath, fullPath, overwrite: true); + } +} diff --git a/examples/CA/Sigil.FileSystem/Sigil.FileSystem.csproj b/examples/CA/Sigil.FileSystem/Sigil.FileSystem.csproj new file mode 100644 index 000000000..694d598cb --- /dev/null +++ b/examples/CA/Sigil.FileSystem/Sigil.FileSystem.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + diff --git a/examples/CA/Sigil.Gcp/GcpKmsOptions.cs b/examples/CA/Sigil.Gcp/GcpKmsOptions.cs new file mode 100644 index 000000000..67323f332 --- /dev/null +++ b/examples/CA/Sigil.Gcp/GcpKmsOptions.cs @@ -0,0 +1,34 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Gcp; + +/// +/// Configuration for connecting to GCP Cloud KMS. +/// Authentication uses Application Default Credentials (ADC) — either gcloud CLI +/// credentials or a service account key file. +/// +public class GcpKmsOptions +{ + /// + /// GCP project ID, e.g. "my-project-123". + /// + public string ProjectId { get; set; } = string.Empty; + + /// + /// GCP location for the key ring, e.g. "us-central1" or "global". + /// + public string LocationId { get; set; } = "us-central1"; + + /// + /// Key ring name. All Sigil-generated keys are created in this ring. + /// + public string KeyRingId { get; set; } = "sigil"; +} diff --git a/examples/CA/Sigil.Gcp/GcpKmsSigningProvider.cs b/examples/CA/Sigil.Gcp/GcpKmsSigningProvider.cs new file mode 100644 index 000000000..40559565b --- /dev/null +++ b/examples/CA/Sigil.Gcp/GcpKmsSigningProvider.cs @@ -0,0 +1,225 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using Google.Api.Gax.ResourceNames; +using Google.Cloud.Kms.V1; +using Google.Protobuf; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sigil.Common.Services.Signing; + +namespace Sigil.Gcp; + +/// +/// Signing provider that delegates to Google Cloud KMS. +/// Private keys never leave Cloud HSM/KMS — only the signature is returned. +/// Authentication uses Application Default Credentials (gcloud CLI or service account). +/// +public sealed class GcpKmsSigningProvider : ISigningProvider +{ + public string ProviderName => "gcp-kms"; + + private readonly GcpKmsOptions _options; + private readonly ILogger _logger; + private readonly Lazy _client; + + public GcpKmsSigningProvider( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + // Lazy-init so the provider can be registered even when GCP isn't configured + _client = new Lazy(() => KeyManagementServiceClient.Create()); + } + + public async Task GenerateKeyAsync( + string keyAlgorithm, int keySize, string? ecdsaCurve = null, + CancellationToken ct = default) + { + var client = _client.Value; + var keyRingName = new KeyRingName(_options.ProjectId, _options.LocationId, _options.KeyRingId); + + // Ensure the key ring exists (CreateKeyRing is idempotent-ish; catch AlreadyExists) + await EnsureKeyRingAsync(client, keyRingName, ct); + + var cryptoKeyId = $"sigil-{Guid.NewGuid():N}"; + var algorithm = MapToKmsAlgorithm(keyAlgorithm, keySize, ecdsaCurve); + + var cryptoKey = new CryptoKey + { + Purpose = CryptoKey.Types.CryptoKeyPurpose.AsymmetricSign, + VersionTemplate = new CryptoKeyVersionTemplate + { + Algorithm = algorithm + } + }; + + var created = await client.CreateCryptoKeyAsync(keyRingName, cryptoKeyId, cryptoKey, ct); + + _logger.LogInformation( + "Created GCP KMS key '{KeyId}' (algorithm: {Algorithm}) in {KeyRing}", + cryptoKeyId, algorithm, keyRingName); + + return new SigningKeyReference("gcp-kms", cryptoKeyId, keyAlgorithm, keySize); + } + + public async Task GetPublicKeyAsync( + SigningKeyReference keyRef, CancellationToken ct = default) + { + var client = _client.Value; + var versionName = BuildKeyVersionName(keyRef.KeyIdentifier); + + // Wait for the key version to be enabled (newly created keys may take a moment) + await WaitForKeyVersionAsync(client, versionName, ct); + + var publicKey = await client.GetPublicKeyAsync(versionName, ct); + var pem = publicKey.Pem; + + if (keyRef.KeyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + var ecdsa = ECDsa.Create(); + ecdsa.ImportFromPem(pem); + return ecdsa; + } + else + { + var rsa = RSA.Create(); + rsa.ImportFromPem(pem); + return rsa; + } + } + + public async Task SignDataAsync( + byte[] data, HashAlgorithmName hashAlgorithm, + SigningKeyReference keyRef, CancellationToken ct = default) + { + var client = _client.Value; + var versionName = BuildKeyVersionName(keyRef.KeyIdentifier); + + // GCP KMS AsymmetricSign expects a pre-computed digest + var digest = ComputeDigest(data, hashAlgorithm); + + var response = await client.AsymmetricSignAsync(versionName, digest, ct); + var signatureBytes = response.Signature.ToByteArray(); + + // GCP KMS returns ECDSA signatures in DER format — same as BouncyCastle expects. + // No format conversion needed (unlike Vault Transit which returns P1363). + + _logger.LogDebug("Signed {Bytes} bytes with GCP KMS key '{KeyId}'", + data.Length, keyRef.KeyIdentifier); + + return signatureBytes; + } + + /// + /// Destroys the primary key version, making the key unusable for signing. + /// The key metadata remains in KMS for audit purposes. + /// + public async Task DestroyKeyVersionAsync(string keyId, CancellationToken ct = default) + { + var client = _client.Value; + var versionName = BuildKeyVersionName(keyId); + + var destroyed = await client.DestroyCryptoKeyVersionAsync(versionName, ct); + _logger.LogInformation("Destroyed GCP KMS key version '{KeyVersion}' (state: {State})", + versionName, destroyed.State); + } + + private CryptoKeyVersionName BuildKeyVersionName(string keyId) + { + return new CryptoKeyVersionName( + _options.ProjectId, + _options.LocationId, + _options.KeyRingId, + keyId, + "1"); // Primary version + } + + private async Task EnsureKeyRingAsync( + KeyManagementServiceClient client, KeyRingName keyRingName, CancellationToken ct) + { + try + { + await client.GetKeyRingAsync(keyRingName, ct); + } + catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + var locationName = new LocationName(_options.ProjectId, _options.LocationId); + await client.CreateKeyRingAsync(locationName, keyRingName.KeyRingId, new KeyRing(), ct); + _logger.LogInformation("Created GCP KMS key ring '{KeyRing}'", keyRingName); + } + } + + private static async Task WaitForKeyVersionAsync( + KeyManagementServiceClient client, CryptoKeyVersionName versionName, CancellationToken ct) + { + // Newly created keys may not be immediately ENABLED; poll briefly + for (int i = 0; i < 10; i++) + { + var version = await client.GetCryptoKeyVersionAsync(versionName, ct); + if (version.State == CryptoKeyVersion.Types.CryptoKeyVersionState.Enabled) + return; + + await Task.Delay(500, ct); + } + + throw new InvalidOperationException( + $"GCP KMS key version '{versionName}' did not reach ENABLED state within timeout."); + } + + private static Digest ComputeDigest(byte[] data, HashAlgorithmName hashAlgorithm) + { + var digest = new Digest(); + var name = hashAlgorithm.Name ?? "SHA256"; + + if (name.Equals("SHA256", StringComparison.OrdinalIgnoreCase)) + { + digest.Sha256 = ByteString.CopyFrom(SHA256.HashData(data)); + } + else if (name.Equals("SHA384", StringComparison.OrdinalIgnoreCase)) + { + digest.Sha384 = ByteString.CopyFrom(SHA384.HashData(data)); + } + else if (name.Equals("SHA512", StringComparison.OrdinalIgnoreCase)) + { + digest.Sha512 = ByteString.CopyFrom(SHA512.HashData(data)); + } + else + { + throw new NotSupportedException($"Hash algorithm '{name}' is not supported by GCP KMS."); + } + + return digest; + } + + private static CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm MapToKmsAlgorithm( + string keyAlgorithm, int keySize, string? ecdsaCurve) + { + if (keyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + return ecdsaCurve?.ToLowerInvariant() switch + { + "nistp256" => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.EcSignP256Sha256, + "nistp384" => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.EcSignP384Sha384, + _ => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.EcSignP384Sha384 + }; + } + + return keySize switch + { + 2048 => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.RsaSignPkcs12048Sha256, + 3072 => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.RsaSignPkcs13072Sha256, + 4096 => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.RsaSignPkcs14096Sha256, + _ => CryptoKeyVersion.Types.CryptoKeyVersionAlgorithm.RsaSignPkcs14096Sha256 + }; + } +} diff --git a/examples/CA/Sigil.Gcp/GcpPublisherOptions.cs b/examples/CA/Sigil.Gcp/GcpPublisherOptions.cs new file mode 100644 index 000000000..0cb91ad57 --- /dev/null +++ b/examples/CA/Sigil.Gcp/GcpPublisherOptions.cs @@ -0,0 +1,16 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Gcp; + +public class GcpPublisherOptions +{ + public string BucketName { get; set; } = string.Empty; +} diff --git a/examples/CA/Sigil.Gcp/GcpPublishingProvider.cs b/examples/CA/Sigil.Gcp/GcpPublishingProvider.cs new file mode 100644 index 000000000..7e3bf4cb6 --- /dev/null +++ b/examples/CA/Sigil.Gcp/GcpPublishingProvider.cs @@ -0,0 +1,72 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Google.Cloud.Storage.V1; +using Microsoft.Extensions.Logging; +using Sigil.Common.Services.Publishing; + +namespace Sigil.Gcp; + +/// +/// Publishes CRLs and certificates to a Google Cloud Storage bucket. +/// Uses Application Default Credentials (ADC) for authentication. +/// The object key is derived from the URL path. +/// +public sealed class GcpPublishingProvider : IPublishingProvider +{ + public string ProviderType => "gcp"; + + private readonly GcpPublisherOptions _options; + private readonly ILogger _logger; + private readonly Lazy _storageClient; + + public GcpPublishingProvider(GcpPublisherOptions options, ILogger logger) + { + _options = options; + _logger = logger; + _storageClient = new Lazy(() => StorageClient.Create()); + } + + public async Task PublishCrlAsync(Uri targetUrl, byte[] crlBytes, CancellationToken ct = default) + { + var objectName = ExtractObjectName(targetUrl); + await UploadAsync(objectName, crlBytes, "application/pkix-crl", ct); + _logger.LogInformation("Published CRL to gs://{Bucket}/{Object} ({Bytes} bytes)", + _options.BucketName, objectName, crlBytes.Length); + } + + public async Task PublishCertificateAsync(Uri targetUrl, byte[] certBytes, CancellationToken ct = default) + { + var objectName = ExtractObjectName(targetUrl); + var contentType = objectName.EndsWith(".crl", StringComparison.OrdinalIgnoreCase) + ? "application/pkix-crl" + : "application/x-x509-ca-cert"; + await UploadAsync(objectName, certBytes, contentType, ct); + _logger.LogInformation("Published certificate to gs://{Bucket}/{Object} ({Bytes} bytes)", + _options.BucketName, objectName, certBytes.Length); + } + + private async Task UploadAsync(string objectName, byte[] bytes, string contentType, CancellationToken ct) + { + using var stream = new MemoryStream(bytes); + await _storageClient.Value.UploadObjectAsync( + _options.BucketName, + objectName, + contentType, + stream, + cancellationToken: ct); + } + + private static string ExtractObjectName(Uri targetUrl) + { + // Strip leading slash to get the GCS object key + return targetUrl.AbsolutePath.TrimStart('/'); + } +} diff --git a/examples/CA/Sigil.Gcp/Sigil.Gcp.csproj b/examples/CA/Sigil.Gcp/Sigil.Gcp.csproj new file mode 100644 index 000000000..6ca9473da --- /dev/null +++ b/examples/CA/Sigil.Gcp/Sigil.Gcp.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/examples/CA/Sigil.ServiceDefaults/Extensions.cs b/examples/CA/Sigil.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..cbf765cf3 --- /dev/null +++ b/examples/CA/Sigil.ServiceDefaults/Extensions.cs @@ -0,0 +1,91 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Sigil.ServiceDefaults; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} diff --git a/examples/CA/Sigil.ServiceDefaults/Sigil.ServiceDefaults.csproj b/examples/CA/Sigil.ServiceDefaults/Sigil.ServiceDefaults.csproj new file mode 100644 index 000000000..310236f0e --- /dev/null +++ b/examples/CA/Sigil.ServiceDefaults/Sigil.ServiceDefaults.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/examples/CA/Sigil.UI/Components/Layout/MainLayout.razor b/examples/CA/Sigil.UI/Components/Layout/MainLayout.razor new file mode 100644 index 000000000..fde0a9f2d --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Layout/MainLayout.razor @@ -0,0 +1,120 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@inherits LayoutComponentBase +@using Microsoft.JSInterop +@inject Sigil.UI.Services.TimeDisplayService TimeDisplay +@inject IJSRuntime JS +@implements IDisposable + + + + + + Sigil + PKI Management + +
+ + @if (settingsOpen) + { +
+
+ Settings + +
+ Theme + + @(themeMode == DesignThemeModes.Dark ? "Light" : "Dark") + +
+ +
+ Time Zone + + @(TimeDisplay.UseLocalTime ? "Local" : "UTC") + +
+
+ } +
+
+
+ + Dashboard + Communities + Certificate Explorer + Templates + Jobs + Import + +
+
+ @Body +
+
+
+
+ + + + +@code { + private DesignThemeModes themeMode = DesignThemeModes.Dark; + private bool hasLoadedTimeZone; + private bool settingsOpen; + + protected override void OnInitialized() + { + TimeDisplay.OnChanged += StateHasChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !hasLoadedTimeZone) + { + hasLoadedTimeZone = true; + try + { + var stored = await JS.InvokeAsync("localStorage.getItem", "sigil-timezone"); + if (stored == "local") + TimeDisplay.SetUseLocalTime(true); + } + catch { } + } + } + + private async Task ToggleTimeZone() + { + TimeDisplay.SetUseLocalTime(!TimeDisplay.UseLocalTime); + try + { + await JS.InvokeVoidAsync("localStorage.setItem", "sigil-timezone", TimeDisplay.UseLocalTime ? "local" : "utc"); + } + catch { } + } + + private void ToggleTheme() + { + themeMode = themeMode == DesignThemeModes.Dark + ? DesignThemeModes.Light + : DesignThemeModes.Dark; + } + + public void Dispose() + { + TimeDisplay.OnChanged -= StateHasChanged; + } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/CertificateExplorer.razor b/examples/CA/Sigil.UI/Components/Pages/CertificateExplorer.razor new file mode 100644 index 000000000..07cac0899 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/CertificateExplorer.razor @@ -0,0 +1,1219 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@page "/explorer" +@page "/explorer/{CommunityId:int}" +@using Sigil.Common.ViewModels +@using Sigil.Common.Services +@using Sigil.UI.Components.Shared +@using System.Security.Cryptography.X509Certificates + +Sigil - Certificate Explorer + + + Certificate Explorer + @if (communityList.Count > 0) + { + + } + + @if (CommunityId > 0) + { + + Import Certificate + + + New Root CA + + } + + +@* Drop zone for importing certificates *@ +@if (showDropZone && CommunityId > 0) +{ + + + Import Certificates + + + +

+ Drop .pfx, .cer, .pem, or .crl files below. + Multiple files accepted. Certificate role (CA / end-entity) is auto-detected. + CRLs are matched to their issuing CA. +

+ + + + +
+ + Drag & drop files or click to select +
+
+
+ + + or + + Upload Folder + + Includes all subfolders + + +
+ + @if (isImportingBatch) + { +
+ + + Processing @importProgress of @importTotal files... + +
+ } + + @if (importErrors.Count > 0) + { + + @importErrors.Count error(s) during import + +
    + @foreach (var err in importErrors) + { +
  • @err
  • + } +
+ } + + @if (importError != null) + { + + @importError + + } +
+} + +@* Password dialog for PFX files *@ + + PFX Password Required + +

Enter the password for @pendingFileName

+ @if (pendingPasswordQueue.Count > 0) + { +

@pendingPasswordQueue.Count more file(s) waiting

+ } + + @if (passwordError != null) + { + + @passwordError + + } +
+ + Import + @if (pendingPasswordQueue.Count > 0) + { + Skip + } + Cancel + +
+ +@* Confirm import dialog showing parsed cert details *@ + + Confirm Import + + @if (parsedCert != null) + { +
+ File + @parsedCert.FileName + Detected Role + + @parsedCert.DetectedRole + + Subject + @parsedCert.Certificate.Subject + Issuer + @parsedCert.Certificate.Issuer + Algorithm + @parsedCert.Algorithm @(parsedCert.KeySize)-bit + Valid + @TimeDisplay.FormatShort(parsedCert.Certificate.NotBefore) to @TimeDisplay.FormatShort(parsedCert.Certificate.NotAfter) + Private Key + @(parsedCert.HasPrivateKey ? "Yes" : "No (public cert only)") + @if (chainMatchDescription != null) + { + Chain Match + @chainMatchDescription + } +
+ + + } +
+ + + Import + + Cancel + +
+ +@* Move to community dialog *@ + + Move to Community + +

Move @selectedNode?.Name to:

+ +
+ + + Move + + Cancel + +
+ +@* CA selection dialog for unmatched certs *@ + + Select Issuing CA + +

+ No matching issuing CA was found for @pendingFileName. +

+

+ Select a CA to store this certificate under. The chain will show as untrusted, which is + useful for testing invalid PKI scenarios. +

+ @if (pendingCaSelectQueue.Count > 0) + { +

@pendingCaSelectQueue.Count more file(s) waiting

+ } + + @if (selectedCaForAssignment != null) + { +
+ Subject: @selectedCaForAssignment.Subject +
+ } +
+ + + Assign + + @if (pendingCaSelectQueue.Count > 0) + { + Skip + } + Cancel + +
+ +@* Issue Certificate dialog *@ + + + @if (issuingCaIdForIssuance == null) + { + New Root CA (Self-Signed) + } + else + { + Issue Certificate — signed by @issuingCaNameForIssuance + } + + +
+ @* Template selection *@ + + @if (selectedTemplate?.Description != null) + { +

@selectedTemplate.Description

+ } + + @* Subject DN *@ + + + @* Certificate Name *@ + + + @* Validity *@ +
+ + +
+ + @* CDP URLs *@ + @if (selectedTemplate?.IncludeCdp == true) + { + CRL Distribution Point URLs + @foreach (var cdp in issuanceCdpUrls) + { + + + + + } + + Add CDP URL + + } + + @* AIA URLs *@ + @if (selectedTemplate?.IncludeAia == true) + { + Authority Information Access URLs + @foreach (var aia in issuanceAiaUrls) + { + + + + + } + + Add AIA URL + + } + + @* Subject Alternative Names *@ + Subject Alternative Names + @foreach (var san in issuanceSans) + { + + + + + + } + + Add SAN + + + @* Key Storage *@ + @if (SigningProvider.ProviderName != "local") + { + + } + + @* PFX Password — only for local key storage *@ + @if (issuanceKeyStorage == "local") + { + + } + else + { +
+ + Private key will be generated and stored in Vault Transit — it never leaves the provider. +
+ } + + @* Template summary *@ + @if (selectedTemplate != null) + { +
+ Template: @selectedTemplate.KeyAlgorithm + @if (selectedTemplate.KeyAlgorithm == "ECDSA") + { + @($" ({selectedTemplate.EcdsaCurve ?? "P-384"})") + } + else + { + @($" {selectedTemplate.KeySize}-bit") + } + | @selectedTemplate.HashAlgorithm + | @selectedTemplate.ValidityDays days + @if (selectedTemplate.IsBasicConstraintsCa) + { + @(" | CA") + @if (selectedTemplate.PathLengthConstraint.HasValue) + { + @($" (path: {selectedTemplate.PathLengthConstraint})") + } + } +
+ } +
+
+ + + @(isIssuing ? "Generating..." : "Generate Certificate") + + + Cancel + + +
+ +@* Re-sign Certificate dialog *@ + + Re-sign Certificate (Same Key) + +
+ + Re-signing creates a new certificate with the same key pair and extensions + but a new serial number and validity period. Downstream certificates that chain to this + certificate via AKI/SKI will continue to validate without re-issuance. + + + @if (selectedNode != null) + { +
+ Certificate + @selectedNode.Name + Subject + @selectedCert?.Subject +
+ } + +
+ + +
+ + +
+
+ + + @(isResigning ? "Re-signing..." : "Re-sign Certificate") + + + Cancel + + +
+ + + Revoke Certificate + +
+ + Revoking a certificate is permanent. The certificate will be marked as revoked + and included in the next CRL generation. + + + @if (selectedNode != null) + { +
+ Certificate + @selectedNode.Name + Subject + @selectedCert?.Subject + Type + @selectedNode.CertificateRole +
+ } + + +
+
+ + + @(isRevoking ? "Revoking..." : "Revoke Certificate") + + + Cancel + + +
+ +@if (isLoadingTree && treeNodes.Count == 0) +{ + + + Loading certificates... + +} +else if (treeNodes.Count > 0) +{ + + + + + @selectedCommunityName + + + @foreach (var root in treeNodes) + { + + + + + + @if (root.KeyStorage == "vault-transit") + { + + } + @foreach (var intermediate in root.Children.Where(c => c.CertificateRole == "IntermediateCA")) + { + + + + + + @if (intermediate.KeyStorage == "vault-transit") + { + + } + @foreach (var subCa in intermediate.Children.Where(c => c.CertificateRole == "IntermediateCA")) + { + @RenderCaTreeItem(subCa) + } + @foreach (var child in intermediate.Children.Where(c => c.CertificateRole == "EndEntity")) + { + + + + + + @if (child.KeyStorage == "vault-transit") + { + + } + + } + @foreach (var crl in intermediate.Children.Where(c => c.CertificateRole == "CRL")) + { + + + + + + + } + + } + @foreach (var issued in root.Children.Where(c => c.CertificateRole == "EndEntity")) + { + + + + + + @if (issued.KeyStorage == "vault-transit") + { + + } + + } + @foreach (var crl in root.Children.Where(c => c.CertificateRole == "CRL")) + { + + + + + + + } + + } + + + + +
+ @if (selectedCrl != null) + { + @* CRL Detail Panel *@ + + + CRL #@selectedCrl.CrlNumber + + + @if (selectedCrl.SignatureValid) + { + Sig Valid + } + else + { + Sig INVALID + } + + + + + + .crl + + + Archive + + + Delete + + + + + +
+ CRL Number + @selectedCrl.CrlNumber + Issuing CA + @selectedCrl.CaName + This Update + @TimeDisplay.Format(selectedCrl.ThisUpdate) + Next Update + @TimeDisplay.Format(selectedCrl.NextUpdate) + Thumbprint (SHA-1) + @selectedCrl.Thumbprint + Authority Key ID + @(selectedCrl.AuthorityKeyIdentifier ?? "(none)") + Algorithm + @selectedCrl.SignatureAlgorithm + Signature + @(selectedCrl.SignatureValid ? "Valid — verified against issuing CA" : "INVALID — does not match issuing CA") + Revoked Certs + @selectedCrl.RevokedCount + File + @(selectedCrl.FileName ?? "(none)") + Imported + @TimeDisplay.Format(selectedCrl.ImportedAt) +
+
+ + @if (selectedCrl.RevokedCertificates.Count > 0) + { + + + + + + + + } + + + + +
+
+ } + else if (selectedCert != null) + { + @* Certificate Detail Panel *@ + + + @if (isRenaming) + { + + + + } + else + { + @selectedNode?.Name + + } + + @if (selectedNodeHasPrivateKey) + { + Operational + } + else if (selectedNodeHasRemoteKey) + { + Vault Transit + } + else + { + Trust Only + } + + + + + + + .cer + + @if (selectedNodeHasPrivateKey) + { + + + .p12 + + } + + Move + + @if (selectedNode?.Status != CertificateStatus.Revoked) + { + + Revoke + + } + + Archive + + + Delete + + @if (selectedNodeCanSign && selectedNode?.CertificateRole is "RootCA" or "IntermediateCA") + { + + Issue Certificate + + } + + Similar + + + Re-key + + @if (selectedNodeCanSign && selectedNode?.CertificateRole is "RootCA" or "IntermediateCA") + { + + Re-sign + + + @(isGeneratingCrl ? "Generating..." : "Regen CRL") + + + @(isPublishingAia ? "Publishing..." : "Publish AIA") + + } + + + + +
+ Subject + @selectedCert.Subject + Issuer + + @selectedCert.Issuer + @if (selectedCert.Subject != selectedCert.Issuer) + { + + } + + Serial Number + @selectedCert.SerialNumber + Thumbprint + @selectedCert.Thumbprint + Not Before + @TimeDisplay.Format(selectedCert.NotBefore) + Not After + @TimeDisplay.Format(selectedCert.NotAfter) + Algorithm + @selectedCert.SignatureAlgorithm.FriendlyName + Public Key + @GetPublicKeyInfo() + Private Key + @(selectedNodeHasPrivateKey ? "Yes (stored locally)" : selectedNodeHasRemoteKey ? "Yes (Vault Transit)" : "No (public cert only)") + Auto Renew + + + +
+
+ + @if (subjectAltNames.Count > 0) + { + +
    + @foreach (var san in subjectAltNames) + { +
  • @san
  • + } +
+
+ } + + @if (chainValidation != null) + { + + + + + @(isRevalidating ? "Validating..." : "Revalidate") + + + + @(isValidatingOnline ? "Resolving..." : "Validate Online") + + @if (chainValidation.IsValid) + { + Chain Valid + } + else + { + Chain Invalid + } + @if (chainValidation.ReachedTrustAnchor) + { + Trust Anchor Found + } + else + { + No Trust Anchor + } + + + @if (chainValidation.Error != null) + { + @chainValidation.Error + } + + @foreach (var link in chainValidation.ChainLinks) + { +
+ + @link.Name + @if (link.IsTrustAnchor) + { + Root + } + @if (link.SignatureValid == true) + { + Sig OK + } + else if (link.SignatureValid == false) + { + Sig Fail + } + @switch (link.CrlStatus) + { + case CrlCheckStatus.Good: + + CRL OK + + @if (link.CrlSource == CrlSource.Downloaded) + { + + Downloaded + } + break; + case CrlCheckStatus.Revoked: + REVOKED + break; + case CrlCheckStatus.NoCrlAvailable: + No CRL + break; + case CrlCheckStatus.CrlExpired: + CRL Expired + break; + case CrlCheckStatus.CrlFetchFailed: + CRL Fetch Failed + break; + } + +
@link.Subject
+ @if (link.CrlSource == CrlSource.Downloaded && link.CrlSourceUrl != null) + { +
+ CRL resolved from: + @link.CrlSourceUrl +
+ } + @if (link.AiaResolved) + { +
+ Issuer resolved via AIA: + @(link.AiaResolvedUrl ?? "unknown") +
+ } + @if (link.HasProblems) + { +
    + @foreach (var problem in link.Problems) + { +
  • @problem
  • + } +
+ } +
+ } +
+ } + + + + + + + + +
+
+ + @* Issuer Detail Panel *@ + @if (issuerCert != null) + { +
+ + + + @issuerNode?.Name + Issuer + + + + + + +
+ Subject + @issuerCert.Subject + Issuer + + @issuerCert.Issuer + @if (issuerCert.Subject != issuerCert.Issuer) + { + + } + + Serial Number + @issuerCert.SerialNumber + Thumbprint + @issuerCert.Thumbprint + Not Before + @TimeDisplay.Format(issuerCert.NotBefore) + Not After + @TimeDisplay.Format(issuerCert.NotAfter) + Algorithm + @issuerCert.SignatureAlgorithm.FriendlyName + Public Key + @GetIssuerPublicKeyInfo() +
+
+ + + + + + + + +
+
+ } + } + else + { + + Select a certificate or CRL from the tree to view details + + } +
+
+
+} +else if (CommunityId > 0 && !showDropZone) +{ + +

No certificates found in this community. + Import a certificate to get started. +

+
+} + +@code { + private RenderFragment RenderCaTreeItem(CertificateChainNodeViewModel caNode) => __builder => + { + + + @if (caNode.CertificateRole == "RootCA") + { + + } + else + { + + } + + + @if (caNode.KeyStorage == "vault-transit") + { + + } + @* Recursive: sub-CA nodes *@ + @foreach (var subCa in caNode.Children.Where(c => c.CertificateRole is "IntermediateCA" or "RootCA")) + { + @RenderCaTreeItem(subCa) + } + @* End-entity certs *@ + @foreach (var issued in caNode.Children.Where(c => c.CertificateRole == "EndEntity")) + { + + + + + + @if (issued.KeyStorage == "vault-transit") + { + + } + + } + @* CRL nodes *@ + @foreach (var crl in caNode.Children.Where(c => c.CertificateRole == "CRL")) + { + + + + + + + } + + }; +} diff --git a/examples/CA/Sigil.UI/Components/Pages/CertificateExplorer.razor.cs b/examples/CA/Sigil.UI/Components/Pages/CertificateExplorer.razor.cs new file mode 100644 index 000000000..c66cf44a8 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/CertificateExplorer.razor.cs @@ -0,0 +1,3091 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.EntityFrameworkCore; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using Sigil.Common.Services; +using Sigil.Common.Services.Jobs; +using Sigil.Common.Services.Signing; +using Sigil.Common.ViewModels; +using Sigil.Gcp; +using Sigil.UI.Services; +using Sigil.Vault; + +namespace Sigil.UI.Components.Pages; + +public partial class CertificateExplorer : IDisposable +{ + [Parameter] public int CommunityId { get; set; } + [SupplyParameterFromQuery(Name = "thumbprint")] public string? SelectedThumbprint { get; set; } + + [Inject] private IDbContextFactory DbFactory { get; set; } = null!; + [Inject] private CertificateParsingService ParsingService { get; set; } = null!; + [Inject] private IToastService ToastService { get; set; } = null!; + [Inject] private IDialogService DialogService { get; set; } = null!; + [Inject] private Asn1ParsingService Asn1Parser { get; set; } = null!; + [Inject] private CrlImportService CrlImporter { get; set; } = null!; + [Inject] private ChainValidationService ChainValidator { get; set; } = null!; + [Inject] private CertificateIssuanceService IssuanceService { get; set; } = null!; + [Inject] private ISigningProvider SigningProvider { get; set; } = null!; + [Inject] private VaultTransitSigningProvider VaultTransitProvider { get; set; } = null!; + [Inject] private GcpKmsSigningProvider GcpKmsProvider { get; set; } = null!; + [Inject] private IHttpClientFactory HttpClientFactory { get; set; } = null!; + [Inject] private IJSRuntime JS { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] private CrlGenerationService CrlGenService { get; set; } = null!; + [Inject] private TimeDisplayService TimeDisplay { get; set; } = null!; + + // Tree state + private List communityList = new(); + private CommunityOption? selectedCommunity; + private string selectedCommunityName = string.Empty; + private List treeNodes = new(); + private CertificateChainNodeViewModel? selectedNode; + private X509Certificate2? selectedCert; + private Asn1Node? asn1Root; + private CrlViewModel? selectedCrl; + private ChainValidationResult? chainValidation; + private bool selectedNodeHasPrivateKey; + private bool selectedNodeHasRemoteKey; + private bool selectedNodeCanSign => selectedNodeHasPrivateKey || selectedNodeHasRemoteKey; + private bool selectedNodeAutoRenew = true; + private bool isGeneratingCrl; + private bool isPublishingAia; + private List subjectAltNames = new(); + private FluentTreeItem? selectedTreeItem; + private int treeVersion; + private bool isLoadingTree; + private bool isRevalidating; + private bool isValidatingOnline; + private bool pendingHighlight; + private Dictionary communityValidations = new(); + + // Rename state + private bool isRenaming; + private string renamingValue = string.Empty; + + // Import state + private bool showDropZone; + private string? importError; + private List importErrors = new(); + private bool isImportingBatch; + private int importProgress; + private int importTotal; + private FluentInputFile? fileInput; + private InputFile? folderInput; + private Queue<(byte[] Bytes, string FileName)> pendingPasswordQueue = new(); + + // Password dialog + private bool passwordDialogHidden = true; + private string pfxPassword = string.Empty; + private string? passwordError; + private string pendingFileName = string.Empty; + private byte[]? pendingFileBytes; + + // Move dialog + private bool moveDialogHidden = true; + private CommunityOption? moveTargetCommunity; + + // Confirm dialog + private bool confirmDialogHidden = true; + private ParsedCertificate? parsedCert; + private string importName = string.Empty; + private string? chainMatchDescription; + private int? matchedParentCaId; + + // CA selection dialog (for unmatched certs) + private bool caSelectDialogHidden = true; + private List availableCas = new(); + private CaSelectOption? selectedCaForAssignment; + private ParsedCertificate? pendingCaSelectParsed; + private Queue<(byte[] Bytes, string FileName, string? Password)> pendingCaSelectQueue = new(); + + // Issuance dialog + private bool issuanceDialogHidden = true; + private bool isIssuing; + private int? issuingCaIdForIssuance; + private string? issuingCaNameForIssuance; + private DateTime? issuingCaNotAfter; + private List availableTemplates = new(); + private CertificateTemplate? selectedTemplate; + private string issuanceSubjectDn = string.Empty; + private string issuanceCertName = string.Empty; + private DateTime? issuanceNotBeforeNullable = DateTime.UtcNow; + private DateTime? issuanceNotAfterNullable = DateTime.UtcNow.AddYears(1); + private List issuanceCdpUrls = new(); + private List issuanceAiaUrls = new(); + private List issuanceSans = new(); + private string issuancePfxPassword = string.Empty; + private string issuanceKeyStorage = "local"; // "local" or "vault-transit" + private bool isRenewMode; + private List renewalSans = new(); + private string renewalSubjectDn = string.Empty; + + // Revoke dialog + private bool revokeDialogHidden = true; + private bool isRevoking; + private RevokeReasonOption selectedRevokeReason = null!; + private static readonly List revokeReasonOptions = new() + { + new(0, "Unspecified"), + new(1, "Key Compromise"), + new(2, "CA Compromise"), + new(3, "Affiliation Changed"), + new(4, "Superseded"), + new(5, "Cessation of Operation"), + new(9, "Privilege Withdrawn"), + }; + + // Re-sign dialog + private bool resignDialogHidden = true; + private bool isResigning; + private DateTime? resignNotBefore = DateTime.UtcNow; + private DateTime? resignNotAfter = DateTime.UtcNow.AddYears(5); + private string resignPfxPassword = string.Empty; + + protected override async Task OnInitializedAsync() + { + TimeDisplay.OnChanged += StateHasChanged; + await using var db = await DbFactory.CreateDbContextAsync(); + + communityList = await db.Communities + .OrderBy(c => c.Name) + .Select(c => new CommunityOption + { + Id = c.Id, + Name = c.Name, + BaseUrls = c.BaseUrls.OrderBy(bu => bu.SortOrder) + .Select(bu => new BaseUrlViewModel { Url = bu.Url, PublishingBasePath = bu.PublishingBasePath }) + .ToList() + }) + .ToListAsync(); + + if (CommunityId > 0) + { + selectedCommunity = communityList.FirstOrDefault(c => c.Id == CommunityId); + await LoadCommunityTreeAsync(CommunityId); + + // Auto-select cert if thumbprint was provided via query string + if (!string.IsNullOrEmpty(SelectedThumbprint)) + { + var node = FindNodeByThumbprint(treeNodes, SelectedThumbprint); + if (node != null) + { + await SelectNode(node); + pendingHighlight = true; + } + + // Clear the query string so it doesn't linger as the user navigates + SelectedThumbprint = null; + Navigation.NavigateTo($"/explorer/{CommunityId}", replace: true); + } + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (pendingHighlight) + { + pendingHighlight = false; + await UpdateTreeHighlightsAsync(); + } + } + + private async Task OnCommunitySelected(CommunityOption? option) + { + if (option != null) + { + selectedCommunity = option; + CommunityId = option.Id; + await LoadCommunityTreeAsync(option.Id); + } + } + + private async Task LoadCommunityTreeAsync(int communityId) + { + isLoadingTree = true; + StateHasChanged(); + + await using var db = await DbFactory.CreateDbContextAsync(); + + var community = await db.Communities.FindAsync(communityId); + selectedCommunityName = community?.Name ?? "Unknown"; + + // Load ALL CAs for this community in a flat list — EF will fix up + // the navigation properties (Parent/Children) automatically since + // all entities are in the same DbContext tracking scope. + var caCerts = await db.CaCertificates + .Where(ca => ca.CommunityId == communityId && !ca.IsArchived) + .Include(ca => ca.IssuedCertificates.Where(i => !i.IsArchived)) + .Include(ca => ca.Crls.Where(c => !c.IsArchived)) + .OrderBy(ca => ca.Name) + .ToListAsync(); + + // EF relationship fix-up populates ca.Children for all loaded entities, + // so BuildTreeNode's recursive walk works at any depth. + + // Validate all certs in one pass (parses CAs once, stored CRLs only) + communityValidations = await ChainValidator.ValidateCommunityAsync(communityId); + + treeNodes = caCerts + .Where(ca => ca.ParentId == null) + .Select(rootCa => BuildTreeNode(rootCa, caCerts, communityValidations)) + .ToList(); + + treeVersion++; + selectedTreeItem = null; + selectedNode = null; + selectedCert?.Dispose(); + selectedCert = null; + selectedCrl = null; + chainValidation = null; + asn1Root = null; + subjectAltNames.Clear(); + isLoadingTree = false; + } + + private static CertificateChainNodeViewModel BuildTreeNode( + CaCertificate ca, + List allCas, + Dictionary validationResults) + { + var caStatus = DeriveStatus(ca.Thumbprint, ca.NotAfter, ca.IsRevoked, validationResults); + + var node = new CertificateChainNodeViewModel + { + Id = ca.Id, + Name = ca.Name, + Subject = ca.Subject, + Thumbprint = ca.Thumbprint, + NotAfter = ca.NotAfter, + CertificateRole = ca.ParentId == null ? "RootCA" : "IntermediateCA", + EntityType = "CaCertificate", + Status = caStatus, + KeyStorage = !string.IsNullOrEmpty(ca.StoreProviderHint) + ? ca.StoreProviderHint[..ca.StoreProviderHint.IndexOf(':')] + : ca.EncryptedPfxBytes != null ? "local" + : null + }; + + foreach (var child in ca.Children.OrderBy(c => c.Name)) + { + node.Children.Add(BuildTreeNode(child, allCas, validationResults)); + } + + foreach (var issued in ca.IssuedCertificates.OrderBy(i => i.Name)) + { + var issuedStatus = DeriveStatus(issued.Thumbprint, issued.NotAfter, issued.IsRevoked, validationResults); + + node.Children.Add(new CertificateChainNodeViewModel + { + Id = issued.Id, + Name = issued.Name, + Subject = issued.Subject, + Thumbprint = issued.Thumbprint, + NotAfter = issued.NotAfter, + CertificateRole = "EndEntity", + EntityType = "IssuedCertificate", + Status = issuedStatus, + KeyStorage = !string.IsNullOrEmpty(issued.StoreProviderHint) + ? issued.StoreProviderHint[..issued.StoreProviderHint.IndexOf(':')] + : issued.EncryptedPfxBytes != null ? "local" + : null + }); + } + + // Add CRL nodes + foreach (var crl in ca.Crls.OrderByDescending(c => c.CrlNumber)) + { + var crlStatus = DateTime.UtcNow > crl.NextUpdate + ? CertificateStatus.Expired + : DateTime.UtcNow > crl.NextUpdate.AddDays(-7) + ? CertificateStatus.Expiring + : CertificateStatus.Valid; + + node.Children.Add(new CertificateChainNodeViewModel + { + Id = crl.Id, + Name = $"CRL #{crl.CrlNumber}" + (crl.FileName != null ? $" ({crl.FileName})" : ""), + Subject = ca.Subject, + NotAfter = crl.NextUpdate, + CertificateRole = "CRL", + EntityType = "Crl", + Status = crlStatus + }); + } + + return node; + } + + private static CertificateStatus DeriveStatus( + string thumbprint, DateTime notAfter, bool isRevoked, + Dictionary validationResults) + { + if (isRevoked) return CertificateStatus.Revoked; + if (DateTime.UtcNow > notAfter) return CertificateStatus.Expired; + + // Check chain validation result + if (validationResults.TryGetValue(thumbprint, out var result)) + { + if (!result.IsValid) + { + // Check if there's a revocation problem in the chain + var hasRevocation = result.ChainLinks + .Any(l => l.CrlStatus == CrlCheckStatus.Revoked); + if (hasRevocation) return CertificateStatus.Revoked; + + return CertificateStatus.Untrusted; + } + } + + if (DateTime.UtcNow > notAfter.AddDays(-30)) return CertificateStatus.Expiring; + return CertificateStatus.Valid; + } + + // --- Tree selection --- + + private async Task OnTreeItemSelected(FluentTreeItem? item) + { + selectedTreeItem = item; + isRenaming = false; + + if (item?.Data is CertificateChainNodeViewModel node) + { + await SelectNode(node); + } + } + + private async Task SelectNode(CertificateChainNodeViewModel node) + { + selectedNode = node; + selectedCert?.Dispose(); + selectedCert = null; + selectedCrl = null; + selectedNodeHasPrivateKey = false; + selectedNodeHasRemoteKey = false; + selectedNodeAutoRenew = true; + chainValidation = null; + asn1Root = null; + subjectAltNames.Clear(); + CloseIssuerDetails(); + + await using var db = await DbFactory.CreateDbContextAsync(); + + // CRL selection + if (node.EntityType == "Crl") + { + var crl = await db.Crls + .Include(c => c.CaCertificate) + .Include(c => c.Revocations) + .FirstOrDefaultAsync(c => c.Id == node.Id); + + if (crl != null) + { + selectedCrl = new CrlViewModel + { + Id = crl.Id, + CaCertificateId = crl.CaCertificateId, + CaName = crl.CaCertificate.Name, + CrlNumber = crl.CrlNumber, + ThisUpdate = crl.ThisUpdate, + NextUpdate = crl.NextUpdate, + SignatureAlgorithm = crl.SignatureAlgorithm, + SignatureValid = crl.SignatureValid, + FileName = crl.FileName, + Thumbprint = Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(crl.RawBytes)), + AuthorityKeyIdentifier = ExtractCrlAki(crl.RawBytes), + RevokedCount = crl.Revocations.Count, + ImportedAt = crl.ImportedAt, + RevokedCertificates = crl.Revocations + .OrderBy(r => r.RevocationDate) + .Select(r => new RevokedCertEntry + { + SerialNumber = r.RevokedCertSerialNumber, + Thumbprint = r.RevokedCertThumbprint, + RevocationDate = r.RevocationDate, + ReasonCode = r.RevocationReason + }) + .ToList() + }; + + // Parse CRL ASN.1 structure + asn1Root = Asn1Parser.Parse(crl.RawBytes); + } + + return; + } + + string? pem = null; + string? sans = null; + + if (node.EntityType == "CaCertificate") + { + var ca = await db.CaCertificates.FindAsync(node.Id); + pem = ca?.X509CertificatePem; + selectedNodeHasPrivateKey = ca?.EncryptedPfxBytes != null; + selectedNodeHasRemoteKey = !string.IsNullOrEmpty(ca?.StoreProviderHint); + selectedNodeAutoRenew = ca?.AutoRenew ?? true; + } + else + { + var issued = await db.IssuedCertificates.FindAsync(node.Id); + pem = issued?.X509CertificatePem; + sans = issued?.SubjectAltNames; + selectedNodeHasPrivateKey = issued?.EncryptedPfxBytes != null; + selectedNodeAutoRenew = issued?.AutoRenew ?? true; + } + + if (!string.IsNullOrEmpty(pem)) + { + try + { + selectedCert = X509Certificate2.CreateFromPem(pem); + asn1Root = Asn1Parser.ParsePem(pem); + + // Use pre-computed result from tree load + if (!string.IsNullOrEmpty(node.Thumbprint) + && communityValidations.TryGetValue(node.Thumbprint, out var cached)) + { + chainValidation = cached; + } + else + { + // Fallback: validate on demand + if (node.EntityType == "CaCertificate") + chainValidation = await ChainValidator.ValidateCaCertificateAsync(node.Id); + else + chainValidation = await ChainValidator.ValidateIssuedCertificateAsync(node.Id); + } + } + catch { } + } + + if (!string.IsNullOrEmpty(sans)) + { + subjectAltNames = sans.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + else if (selectedCert != null) + { + var sanExt = selectedCert.Extensions["2.5.29.17"]; + if (sanExt != null) + { + try + { + subjectAltNames = sanExt.Format(multiLine: true) + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + catch { } + } + } + + await UpdateTreeHighlightsAsync(); + } + + private async Task RevalidateSelectedAsync() + { + if (selectedNode == null || selectedNode.EntityType == "Crl") return; + + isRevalidating = true; + StateHasChanged(); + + try + { + // Full validation with online CRL resolution + if (selectedNode.EntityType == "CaCertificate") + chainValidation = await ChainValidator.ValidateCaCertificateAsync(selectedNode.Id); + else + chainValidation = await ChainValidator.ValidateIssuedCertificateAsync(selectedNode.Id); + + // Update the stored results and tree node status + if (!string.IsNullOrEmpty(selectedNode.Thumbprint) && chainValidation != null) + { + communityValidations[selectedNode.Thumbprint] = chainValidation; + selectedNode.Status = DeriveStatus( + selectedNode.Thumbprint, selectedNode.NotAfter, false, communityValidations); + } + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"Validation failed: {ex.Message}"); + } + finally + { + isRevalidating = false; + StateHasChanged(); + } + } + + private async Task GenerateCrlForSelectedAsync() + { + if (isGeneratingCrl || selectedNode == null) return; + if (selectedNode.CertificateRole is not ("RootCA" or "IntermediateCA")) return; + + isGeneratingCrl = true; + StateHasChanged(); + + try + { + var result = await CrlGenService.GenerateCrlAsync(selectedNode.Id); + if (result.IsSuccess) + { + ToastService.ShowSuccess($"CRL #{result.CrlNumber} generated ({result.RevokedCount} revoked certs)"); + await LoadCommunityTreeAsync(CommunityId); + } + else + { + ToastService.ShowCopyableError($"CRL generation failed: {result.Error}"); + } + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"CRL generation failed: {ex.Message}"); + } + finally + { + isGeneratingCrl = false; + StateHasChanged(); + } + } + + private async Task PublishAiaForSelectedAsync() + { + if (isPublishingAia || selectedNode == null) return; + if (selectedNode.CertificateRole is not ("RootCA" or "IntermediateCA")) return; + + isPublishingAia = true; + StateHasChanged(); + + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var ca = await db.CaCertificates.FindAsync(selectedNode.Id); + if (ca == null) + { + ToastService.ShowCopyableError("CA certificate not found."); + return; + } + + var cert = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var baseUrls = await db.CommunityBaseUrls + .Where(bu => bu.CommunityId == ca.CommunityId && bu.PublishingBasePath != null) + .ToListAsync(); + + if (baseUrls.Count == 0) + { + ToastService.ShowWarning("No publishing paths configured on this community's base URLs."); + return; + } + + var published = 0; + foreach (var baseUrl in baseUrls) + { + if (string.IsNullOrEmpty(baseUrl.PublishingBasePath)) continue; + + var certPath = Path.GetFullPath(Path.Combine(baseUrl.PublishingBasePath, "certs", $"{ca.Name}.cer")); + var certDir = Path.GetDirectoryName(certPath); + if (!string.IsNullOrEmpty(certDir)) + Directory.CreateDirectory(certDir); + + var tempPath = certPath + ".tmp"; + await File.WriteAllBytesAsync(tempPath, cert.RawData); + File.Move(tempPath, certPath, overwrite: true); + published++; + } + + ToastService.ShowSuccess($"Published {ca.Name}.cer to {published} endpoint(s)"); + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"AIA publish failed: {ex.Message}"); + } + finally + { + isPublishingAia = false; + StateHasChanged(); + } + } + + private async Task EnsureIssuerPublishedAsync(int issuingCaId) + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var ca = await db.CaCertificates.FindAsync(issuingCaId); + if (ca == null) return; + + var baseUrls = await db.CommunityBaseUrls + .Where(bu => bu.CommunityId == ca.CommunityId && bu.PublishingBasePath != null) + .ToListAsync(); + + if (baseUrls.Count == 0) return; + + var cert = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + + foreach (var baseUrl in baseUrls) + { + if (string.IsNullOrEmpty(baseUrl.PublishingBasePath)) continue; + + var certPath = Path.GetFullPath(Path.Combine(baseUrl.PublishingBasePath, "certs", $"{ca.Name}.cer")); + if (!File.Exists(certPath)) + { + var certDir = Path.GetDirectoryName(certPath); + if (!string.IsNullOrEmpty(certDir)) + Directory.CreateDirectory(certDir); + + var tempPath = certPath + ".tmp"; + await File.WriteAllBytesAsync(tempPath, cert.RawData); + File.Move(tempPath, certPath, overwrite: true); + } + + var crlPath = Path.GetFullPath(Path.Combine(baseUrl.PublishingBasePath, "crls", $"{ca.Name}.crl")); + if (!File.Exists(crlPath)) + { + await CrlGenService.GenerateCrlAsync(issuingCaId); + } + } + } + catch (Exception ex) + { + // Best-effort; URL validation will catch any remaining issues + } + } + + private async Task ValidateOnlineAsync() + { + if (selectedNode == null || selectedNode.EntityType == "Crl") return; + + isValidatingOnline = true; + StateHasChanged(); + + try + { + if (selectedNode.EntityType == "CaCertificate") + chainValidation = await ChainValidator.ValidateCaCertificateOnlineAsync(selectedNode.Id); + else + chainValidation = await ChainValidator.ValidateIssuedCertificateOnlineAsync(selectedNode.Id); + + if (!string.IsNullOrEmpty(selectedNode.Thumbprint) && chainValidation != null) + { + communityValidations[selectedNode.Thumbprint] = chainValidation; + selectedNode.Status = DeriveStatus( + selectedNode.Thumbprint, selectedNode.NotAfter, false, communityValidations); + } + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"Online validation failed: {ex.Message}"); + } + finally + { + isValidatingOnline = false; + StateHasChanged(); + } + } + + // --- Rename Methods --- + + private void StartRename() + { + if (selectedNode == null) return; + renamingValue = selectedNode.Name; + isRenaming = true; + } + + private void CancelRename() + { + isRenaming = false; + } + + private async Task OnRenameKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e) + { + if (e.Key == "Enter") await SaveRenameAsync(); + else if (e.Key == "Escape") CancelRename(); + } + + private async Task SaveRenameAsync() + { + if (selectedNode == null || string.IsNullOrWhiteSpace(renamingValue)) return; + + var trimmed = renamingValue.Trim(); + if (trimmed == selectedNode.Name) + { + isRenaming = false; + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + + if (selectedNode.EntityType == "CaCertificate") + { + var ca = await db.CaCertificates.FindAsync(selectedNode.Id); + if (ca != null) + { + ca.Name = trimmed; + await db.SaveChangesAsync(); + } + } + else if (selectedNode.EntityType == "IssuedCertificate") + { + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued != null) + { + issued.Name = trimmed; + await db.SaveChangesAsync(); + } + } + + // Update the tree node in-place so the tree reflects the new name + selectedNode.Name = trimmed; + isRenaming = false; + StateHasChanged(); + } + + private string GetCerDownloadUrl() + { + if (selectedNode == null) return "#"; + return selectedNode.EntityType == "CaCertificate" + ? $"/api/ca/{selectedNode.Id}/download/cer" + : $"/api/issued/{selectedNode.Id}/download/cer"; + } + + private string GetPfxDownloadUrl() + { + if (selectedNode == null) return "#"; + return selectedNode.EntityType == "CaCertificate" + ? $"/api/ca/{selectedNode.Id}/download/p12" + : $"/api/issued/{selectedNode.Id}/download/p12"; + } + + private async Task FindCaBySkiAsync(SigilDbContext db, string authorityKeyIdentifier) + { + var cas = await db.CaCertificates + .Where(ca => ca.CommunityId == CommunityId) + .ToListAsync(); + + foreach (var ca in cas) + { + try + { + using var caCert = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var skiExt = caCert.Extensions["2.5.29.14"]; + if (skiExt != null) + { + var ski = new X509SubjectKeyIdentifierExtension(skiExt, skiExt.Critical); + if (ski.SubjectKeyIdentifier == authorityKeyIdentifier) + { + return ca.Id; + } + } + } + catch { } + } + + return null; + } + + /// + /// Finds the issuing CA by matching the cert's Issuer DN against CA Subject DNs, + /// then verifying the signature. Used as fallback when AKI/SKI match fails. + /// + private async Task FindCaByDnAndSignatureAsync(SigilDbContext db, X509Certificate2 cert) + { + var cas = await db.CaCertificates + .Where(ca => ca.CommunityId == CommunityId) + .ToListAsync(); + + var bcParser = new Org.BouncyCastle.X509.X509CertificateParser(); + var bcCert = bcParser.ReadCertificate(cert.RawData); + + foreach (var ca in cas) + { + try + { + using var caCert = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var bcCa = bcParser.ReadCertificate(caCert.RawData); + + if (bcCa.SubjectDN.Equivalent(bcCert.IssuerDN)) + { + bcCert.Verify(bcCa.GetPublicKey()); + return ca.Id; // Signature verified — this is the issuer + } + } + catch { } + } + + return null; + } + + private static string NodeColor(CertificateStatus status) => status switch + { + CertificateStatus.Expired => "#e94560", + CertificateStatus.Revoked => "#9c27b0", + CertificateStatus.Untrusted => "#d32f2f", + CertificateStatus.Expiring => "#ff9800", + _ => "" + }; + + private static bool IsError(CertificateStatus status) => + status is CertificateStatus.Expired or CertificateStatus.Revoked or CertificateStatus.Untrusted; + + private string GetPublicKeyInfo() + { + if (selectedCert == null) return "Unknown"; + var rsa = selectedCert.GetRSAPublicKey(); + if (rsa != null) return $"RSA {rsa.KeySize}-bit"; + var ecdsa = selectedCert.GetECDsaPublicKey(); + if (ecdsa != null) return $"ECDSA {ecdsa.KeySize}-bit"; + return "Unknown"; + } + + // --- Drag & drop import --- + + private async Task OnFilesCompleted(IEnumerable files) + { + importError = null; + importErrors.Clear(); + + var fileList = files.Where(f => f.LocalFile != null && !string.IsNullOrEmpty(f.Name)).ToList(); + + if (fileList.Count == 0) return; + + // Single file — use the interactive confirm dialog flow + if (fileList.Count == 1) + { + var args = fileList[0]; + try + { + var fileBytes = await File.ReadAllBytesAsync(args.LocalFile!.FullName); + await ProcessUploadedFile(fileBytes, args.Name); + } + catch (Exception ex) + { + importError = $"Failed to read '{args.Name}': {ex.Message}"; + } + return; + } + + // Multiple files — batch import without individual confirm dialogs + await BatchImportFiles(fileList.Select(f => (f.LocalFile!.FullName, f.Name)).ToList()); + } + + private async Task OnFolderSelected(InputFileChangeEventArgs e) + { + importError = null; + importErrors.Clear(); + + var validExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { ".pfx", ".p12", ".cer", ".crt", ".pem", ".der", ".crl" }; + + var filesToProcess = new List<(string TempPath, string FileName)>(); + + foreach (var file in e.GetMultipleFiles(500)) + { + var ext = Path.GetExtension(file.Name).ToLowerInvariant(); + if (!validExtensions.Contains(ext)) continue; + + // Save browser file to temp + var tempPath = Path.GetTempFileName(); + try + { + await using var stream = file.OpenReadStream(10 * 1024 * 1024); + await using var fs = File.Create(tempPath); + await stream.CopyToAsync(fs); + filesToProcess.Add((tempPath, file.Name)); + } + catch (Exception ex) + { + importErrors.Add($"Failed to read '{file.Name}': {ex.Message}"); + } + } + + if (filesToProcess.Count > 0) + { + await BatchImportFiles(filesToProcess); + + // Cleanup temp files + foreach (var (tempPath, _) in filesToProcess) + { + try { File.Delete(tempPath); } catch { } + } + } + } + + private async Task BatchImportFiles(List<(string FilePath, string FileName)> files) + { + isImportingBatch = true; + importTotal = files.Count; + importProgress = 0; + int successCount = 0; + + // Read all files and detect roles so we can sort CAs before end-entity certs + var fileEntries = new List<(byte[] Bytes, string FileName, int SortOrder)>(); + foreach (var (filePath, fileName) in files) + { + try + { + var fileBytes = await File.ReadAllBytesAsync(filePath); + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + + int sortOrder; + if (ext == ".crl") + { + sortOrder = 3; // CRLs last + } + else + { + // Quick-parse to detect role for sorting + var parsed = ext is ".pfx" or ".p12" + ? (ParsingService.Parse(fileBytes, fileName, "") ?? ParsingService.Parse(fileBytes, fileName, "udap-test")) + : ParsingService.Parse(fileBytes, fileName); + + if (parsed?.DetectedRole == DetectedCertRole.RootCa) + sortOrder = 0; // Roots first + else if (parsed?.DetectedRole == DetectedCertRole.IntermediateCa) + sortOrder = 1; // Then intermediates + else + sortOrder = 2; // End-entity certs after CAs + parsed?.Certificate.Dispose(); + } + + fileEntries.Add((fileBytes, fileName, sortOrder)); + } + catch (Exception ex) + { + importErrors.Add($"Failed to read '{fileName}': {ex.Message}"); + } + } + + var sorted = fileEntries.OrderBy(f => f.SortOrder).ToList(); + importTotal = sorted.Count; + + // First pass + var retryList = new List<(byte[] Bytes, string FileName)>(); + foreach (var (fileBytes, fileName, _) in sorted) + { + importProgress++; + StateHasChanged(); + + try + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + + if (ext == ".crl") + { + var result = await CrlImporter.ImportCrlAsync(fileBytes, fileName, CommunityId); + if (result.IsSuccess) + successCount++; + else + importErrors.Add($"{fileName}: {result.Error}"); + } + else + { + var errorCountBefore = importErrors.Count; + var imported = await TryAutoImportCert(fileBytes, fileName); + if (imported) + successCount++; + else if (importErrors.Count > errorCountBefore) + retryList.Add((fileBytes, fileName)); // Had an error (not just queued for password) + } + } + catch (Exception ex) + { + importErrors.Add($"{fileName}: {ex.Message}"); + } + } + + // Second pass: retry files that failed (CAs should now exist) + if (retryList.Count > 0) + { + // Clear errors from first pass for files we're retrying + var retryNames = retryList.Select(r => r.FileName).ToHashSet(); + importErrors.RemoveAll(e => retryNames.Any(n => e.StartsWith($"{n}:"))); + + foreach (var (fileBytes, fileName) in retryList) + { + try + { + var imported = await TryAutoImportCert(fileBytes, fileName); + if (imported) + successCount++; + } + catch (Exception ex) + { + importErrors.Add($"{fileName}: {ex.Message}"); + } + } + } + + isImportingBatch = false; + + if (successCount > 0) + { + ToastService.ShowCopyableSuccess($"Imported {successCount} of {files.Count} files."); + await LoadCommunityTreeAsync(CommunityId); + } + + if (importErrors.Count > 0 && successCount == 0 + && pendingPasswordQueue.Count == 0 && pendingCaSelectQueue.Count == 0) + { + importError = "No files were imported successfully."; + } + + // Process queued PFX files that need manual password entry, then unmatched certs + if (pendingPasswordQueue.Count > 0) + ProcessNextPendingPassword(); + else if (pendingCaSelectQueue.Count > 0) + ProcessNextPendingCaSelect(); + } + + /// + /// Auto-imports a certificate without the confirm dialog. + /// Tries empty password, then "udap-test" for PFX files. + /// + private async Task TryAutoImportCert(byte[] fileBytes, string fileName) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + ParsedCertificate? parsed = null; + string? usedPassword = null; + + if (ext is ".pfx" or ".p12") + { + parsed = ParsingService.Parse(fileBytes, fileName, ""); + if (parsed != null) usedPassword = ""; + + if (parsed == null) + { + parsed = ParsingService.Parse(fileBytes, fileName, "udap-test"); + if (parsed != null) usedPassword = "udap-test"; + } + + if (parsed == null) + { + // Queue for manual password entry after batch completes + pendingPasswordQueue.Enqueue((fileBytes, fileName)); + return false; + } + } + else + { + parsed = ParsingService.Parse(fileBytes, fileName); + if (parsed == null) + { + importErrors.Add($"{fileName}: Could not parse certificate"); + return false; + } + } + + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var cert = parsed.Certificate; + var thumbprint = cert.Thumbprint; + + // Check for existing cert with same thumbprint — merge if found + var existingCa = await db.CaCertificates + .FirstOrDefaultAsync(c => c.Thumbprint == thumbprint && c.CommunityId == CommunityId); + + if (existingCa != null) + { + // Merge: upgrade public-only to operational if PFX now available + if (parsed.HasPrivateKey && existingCa.EncryptedPfxBytes == null) + { + existingCa.EncryptedPfxBytes = fileBytes; + existingCa.PfxPassword = usedPassword; + await db.SaveChangesAsync(); + parsed.Certificate.Dispose(); + return true; + } + + // Already have this cert (same thumbprint, already has key or both public) + parsed.Certificate.Dispose(); + return true; // Not an error, just a no-op + } + + var existingIssued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(c => c.Thumbprint == thumbprint + && c.IssuingCaCertificate.CommunityId == CommunityId); + + if (existingIssued != null) + { + if (parsed.HasPrivateKey && existingIssued.EncryptedPfxBytes == null) + { + existingIssued.EncryptedPfxBytes = fileBytes; + existingIssued.PfxPassword = usedPassword; + await db.SaveChangesAsync(); + parsed.Certificate.Dispose(); + return true; + } + + parsed.Certificate.Dispose(); + return true; + } + + // New cert — find where it belongs in the chain + if (parsed.DetectedRole is DetectedCertRole.RootCa or DetectedCertRole.IntermediateCa) + { + int? parentId = null; + if (parsed.DetectedRole == DetectedCertRole.IntermediateCa) + { + if (parsed.AuthorityKeyIdentifier != null) + parentId = await FindCaBySkiAsync(db, parsed.AuthorityKeyIdentifier); + + // Fallback: match by Issuer DN + signature verification + parentId ??= await FindCaByDnAndSignatureAsync(db, cert); + } + + db.CaCertificates.Add(new CaCertificate + { + CommunityId = CommunityId, + ParentId = parentId, + Name = Path.GetFileNameWithoutExtension(fileName), + Subject = cert.Subject, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsed.HasPrivateKey ? fileBytes : null, + PfxPassword = parsed.HasPrivateKey ? usedPassword : null, + Thumbprint = thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsed.Algorithm, + KeySize = parsed.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CertSecurityLevel = CertSecurityLevel.Software, + Enabled = true + }); + } + else + { + int? issuingCaId = null; + if (parsed.AuthorityKeyIdentifier != null) + { + issuingCaId = await FindCaBySkiAsync(db, parsed.AuthorityKeyIdentifier); + } + + // Fallback: match by Issuer DN + signature verification + issuingCaId ??= await FindCaByDnAndSignatureAsync(db, cert); + + if (issuingCaId == null) + { + // Queue for manual CA selection + pendingCaSelectQueue.Enqueue((fileBytes, fileName, usedPassword)); + parsed.Certificate.Dispose(); + return false; + } + + db.IssuedCertificates.Add(new IssuedCertificate + { + IssuingCaCertificateId = issuingCaId.Value, + Name = Path.GetFileNameWithoutExtension(fileName), + Subject = cert.Subject, + SubjectAltNames = parsed.SubjectAltNames, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsed.HasPrivateKey ? fileBytes : null, + PfxPassword = parsed.HasPrivateKey ? usedPassword : null, + Thumbprint = thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsed.Algorithm, + KeySize = parsed.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + Enabled = true + }); + } + + await db.SaveChangesAsync(); + parsed.Certificate.Dispose(); + return true; + } + catch (Exception ex) + { + importErrors.Add($"{fileName}: {ex.Message}"); + parsed.Certificate.Dispose(); + return false; + } + } + + private async Task TriggerFolderUpload() + { + await JS.InvokeVoidAsync("sigilFolderUpload", "folder-input"); + } + + // --- Auto Renew --- + + private async Task ToggleAutoRenewAsync(bool enabled) + { + if (selectedNode == null) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + + if (selectedNode.EntityType == "CaCertificate") + { + var ca = await db.CaCertificates.FindAsync(selectedNode.Id); + if (ca != null) + { + ca.AutoRenew = enabled; + await db.SaveChangesAsync(); + } + } + else if (selectedNode.EntityType == "IssuedCertificate") + { + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued != null) + { + issued.AutoRenew = enabled; + await db.SaveChangesAsync(); + } + } + + selectedNodeAutoRenew = enabled; + } + + // --- Delete & Move --- + + private async Task ArchiveSelectedAsync() + { + if (selectedNode == null) return; + + var dialog = await DialogService.ShowConfirmationAsync( + $"Archive '{selectedNode.Name}'? It will be hidden from the tree but preserved in the database.", + "Archive", "Cancel", "Confirm Archive"); + var result = await dialog.Result; + + if (result.Cancelled) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + var now = DateTime.UtcNow; + + switch (selectedNode.EntityType) + { + case "CaCertificate": + var ca = await db.CaCertificates.FindAsync(selectedNode.Id); + if (ca != null) { ca.IsArchived = true; ca.ArchivedAt = now; } + break; + case "IssuedCertificate": + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued != null) { issued.IsArchived = true; issued.ArchivedAt = now; } + break; + case "Crl": + var crl = await db.Crls.FindAsync(selectedNode.Id); + if (crl != null) { crl.IsArchived = true; crl.ArchivedAt = now; } + break; + } + + await db.SaveChangesAsync(); + ToastService.ShowCopyableSuccess($"Archived '{selectedNode.Name}'"); + + await ClearSelectionAndReloadTreeAsync(); + } + + private async Task DeleteSelectedAsync() + { + if (selectedNode == null) return; + + var dialog = await DialogService.ShowConfirmationAsync( + $"Permanently delete '{selectedNode.Name}'? This cannot be undone.", + "Delete Forever", "Cancel", "Confirm Delete"); + var result = await dialog.Result; + + if (result.Cancelled) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + + switch (selectedNode.EntityType) + { + case "CaCertificate": + var ca = await db.CaCertificates + .Include(c => c.IssuedCertificates) + .Include(c => c.Crls).ThenInclude(c => c.Revocations) + .Include(c => c.Children) + .FirstOrDefaultAsync(c => c.Id == selectedNode.Id); + if (ca != null) + { + if (ca.Children.Count > 0 || ca.IssuedCertificates.Count > 0) + { + ToastService.ShowCopyableError( + $"Cannot delete '{ca.Name}': it has {ca.Children.Count} child CA(s) and {ca.IssuedCertificates.Count} issued cert(s). Delete or move them first."); + return; + } + // Delete Vault Transit key if applicable + await DeleteRemoteKeyAsync(ca.StoreProviderHint); + // Remove associated CRLs and their revocations + foreach (var crl in ca.Crls.ToList()) + { + db.CertificateRevocations.RemoveRange(crl.Revocations); + db.Crls.Remove(crl); + } + db.CaCertificates.Remove(ca); + } + break; + case "IssuedCertificate": + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued != null) + { + await DeleteRemoteKeyAsync(issued.StoreProviderHint); + db.IssuedCertificates.Remove(issued); + } + break; + case "Crl": + var crl2 = await db.Crls + .Include(c => c.Revocations) + .FirstOrDefaultAsync(c => c.Id == selectedNode.Id); + if (crl2 != null) + { + db.CertificateRevocations.RemoveRange(crl2.Revocations); + db.Crls.Remove(crl2); + } + break; + } + + await db.SaveChangesAsync(); + ToastService.ShowCopyableSuccess($"Permanently deleted '{selectedNode.Name}'"); + + await ClearSelectionAndReloadTreeAsync(); + } + + /// + /// Deletes a remote signing key (Vault Transit or GCP KMS) if the StoreProviderHint indicates one. + /// + private async Task DeleteRemoteKeyAsync(string? storeProviderHint) + { + if (string.IsNullOrEmpty(storeProviderHint)) + return; + + try + { + if (storeProviderHint.StartsWith("vault-transit:")) + { + var keyName = storeProviderHint["vault-transit:".Length..]; + if (!string.IsNullOrEmpty(keyName)) + await VaultTransitProvider.DeleteKeyAsync(keyName); + } + else if (storeProviderHint.StartsWith("gcp-kms:")) + { + var keyId = storeProviderHint["gcp-kms:".Length..]; + if (!string.IsNullOrEmpty(keyId)) + await GcpKmsProvider.DestroyKeyVersionAsync(keyId); + } + } + catch (Exception ex) + { + // Log but don't block the DB delete — the remote key is orphaned but that's better + // than leaving the DB record pointing to a deleted cert + ToastService.ShowCopyableError($"Remote key cleanup failed: {ex.Message}"); + } + } + + private async Task ClearSelectionAndReloadTreeAsync() + { + selectedTreeItem = null; + selectedNode = null; + selectedCert?.Dispose(); + selectedCert = null; + selectedCrl = null; + chainValidation = null; + asn1Root = null; + CloseIssuerDetails(); + + await LoadCommunityTreeAsync(CommunityId); + } + + private void ShowMoveDialog() + { + moveTargetCommunity = null; + moveDialogHidden = false; + } + + private async Task MoveSelectedAsync() + { + if (selectedNode == null || moveTargetCommunity == null) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + var targetId = moveTargetCommunity.Id; + + switch (selectedNode.EntityType) + { + case "CaCertificate": + var ca = await db.CaCertificates.FindAsync(selectedNode.Id); + if (ca != null) + { + ca.CommunityId = targetId; + ca.ParentId = null; // Detach from current parent — will need re-linking in target + } + break; + case "IssuedCertificate": + var issued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(i => i.Id == selectedNode.Id); + if (issued != null) + { + // Find a matching CA in the target community by AKI/SKI + int? newIssuingCaId = null; + try + { + using var issuedCert = X509Certificate2.CreateFromPem(issued.X509CertificatePem); + var akiExt = issuedCert.Extensions["2.5.29.35"]; + if (akiExt?.RawData != null && akiExt.RawData.Length >= 6) + { + var data = akiExt.RawData; + if (data[2] == 0x80) + { + var len = data[3]; + var keyId = new byte[len]; + Array.Copy(data, 4, keyId, 0, len); + var aki = Convert.ToHexString(keyId); + + var targetCas = await db.CaCertificates + .Where(c => c.CommunityId == targetId) + .ToListAsync(); + + foreach (var targetCa in targetCas) + { + using var tCert = X509Certificate2.CreateFromPem(targetCa.X509CertificatePem); + var skiExt = tCert.Extensions["2.5.29.14"]; + if (skiExt != null) + { + var ski = new X509SubjectKeyIdentifierExtension(skiExt, skiExt.Critical); + if (ski.SubjectKeyIdentifier == aki) + { + newIssuingCaId = targetCa.Id; + break; + } + } + } + } + } + } + catch { } + + if (newIssuingCaId == null) + { + // Fallback: first CA in target + var fallbackCa = await db.CaCertificates + .Where(c => c.CommunityId == targetId) + .OrderByDescending(c => c.ParentId) + .FirstOrDefaultAsync(); + newIssuingCaId = fallbackCa?.Id; + } + + if (newIssuingCaId == null) + { + ToastService.ShowCopyableError("Target community has no CA certificates"); + moveDialogHidden = true; + return; + } + + issued.IssuingCaCertificateId = newIssuingCaId.Value; + } + break; + } + + await db.SaveChangesAsync(); + moveDialogHidden = true; + + ToastService.ShowCopyableSuccess($"Moved '{selectedNode.Name}' to '{moveTargetCommunity.Name}'"); + + selectedNode = null; + selectedCert?.Dispose(); + selectedCert = null; + chainValidation = null; + + await LoadCommunityTreeAsync(CommunityId); + } + + private async Task ProcessUploadedFile(byte[] fileBytes, string fileName) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + + if (ext == ".crl") + { + await ImportCrlAsync(fileBytes, fileName); + return; + } + + if (ext is ".pfx" or ".p12") + { + // Try with empty password first + var parsed = ParsingService.Parse(fileBytes, fileName, ""); + if (parsed == null) + { + // Try common default + parsed = ParsingService.Parse(fileBytes, fileName, "udap-test"); + } + + if (parsed != null) + { + await ShowConfirmDialogAsync(parsed); + } + else + { + // Need password + pendingFileBytes = fileBytes; + pendingFileName = fileName; + pfxPassword = string.Empty; + passwordError = null; + passwordDialogHidden = false; + } + } + else + { + var parsed = ParsingService.Parse(fileBytes, fileName); + if (parsed != null) + { + await ShowConfirmDialogAsync(parsed); + } + else + { + importError = $"Could not parse certificate from '{fileName}'. Ensure it is a valid certificate file."; + } + } + } + + private void ProcessNextPendingPassword() + { + if (pendingPasswordQueue.Count == 0) + { + // Password queue done — process any unmatched certs + if (pendingCaSelectQueue.Count > 0) + ProcessNextPendingCaSelect(); + return; + } + + var (bytes, name) = pendingPasswordQueue.Dequeue(); + pendingFileBytes = bytes; + pendingFileName = name; + pfxPassword = string.Empty; + passwordError = null; + passwordDialogHidden = false; + } + + private async Task ImportWithPasswordAsync() + { + if (pendingFileBytes == null) return; + + passwordError = null; + var parsed = ParsingService.Parse(pendingFileBytes, pendingFileName, pfxPassword); + if (parsed != null) + { + passwordDialogHidden = true; + passwordError = null; + pendingFileBytes = null; + + // In batch mode (queue has items or came from batch), auto-import + if (pendingPasswordQueue.Count > 0) + { + await TryAutoImportCertWithParsed(parsed, pendingFileName, pfxPassword); + await LoadCommunityTreeAsync(CommunityId); + ProcessNextPendingPassword(); + } + else + { + await ShowConfirmDialogAsync(parsed); + } + } + else + { + // Keep dialog open — let user retry with a different password + passwordError = "Incorrect password. Try again."; + pfxPassword = string.Empty; + } + } + + private async Task SkipPasswordFile() + { + passwordDialogHidden = true; + passwordError = null; + importErrors.Add($"{pendingFileName}: Skipped (no password provided)"); + pendingFileBytes = null; + ProcessNextPendingPassword(); + } + + private void CancelPasswordDialog() + { + passwordDialogHidden = true; + passwordError = null; + pendingFileBytes = null; + pendingPasswordQueue.Clear(); + } + + /// + /// Auto-imports a parsed cert (used after successful password entry in batch mode). + /// + private async Task TryAutoImportCertWithParsed(ParsedCertificate parsed, string fileName, string password) + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var cert = parsed.Certificate; + var thumbprint = cert.Thumbprint; + + // Check for existing + var existingCa = await db.CaCertificates + .FirstOrDefaultAsync(c => c.Thumbprint == thumbprint && c.CommunityId == CommunityId); + if (existingCa != null) + { + if (parsed.HasPrivateKey && existingCa.EncryptedPfxBytes == null) + { + existingCa.EncryptedPfxBytes = parsed.RawFileBytes; + existingCa.PfxPassword = password; + await db.SaveChangesAsync(); + } + parsed.Certificate.Dispose(); + ToastService.ShowCopyableSuccess($"Imported '{fileName}'"); + return; + } + + var existingIssued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(c => c.Thumbprint == thumbprint + && c.IssuingCaCertificate.CommunityId == CommunityId); + if (existingIssued != null) + { + if (parsed.HasPrivateKey && existingIssued.EncryptedPfxBytes == null) + { + existingIssued.EncryptedPfxBytes = parsed.RawFileBytes; + existingIssued.PfxPassword = password; + await db.SaveChangesAsync(); + } + parsed.Certificate.Dispose(); + ToastService.ShowCopyableSuccess($"Imported '{fileName}'"); + return; + } + + if (parsed.DetectedRole is DetectedCertRole.RootCa or DetectedCertRole.IntermediateCa) + { + int? parentId = null; + if (parsed.DetectedRole == DetectedCertRole.IntermediateCa) + { + if (parsed.AuthorityKeyIdentifier != null) + parentId = await FindCaBySkiAsync(db, parsed.AuthorityKeyIdentifier); + + parentId ??= await FindCaByDnAndSignatureAsync(db, cert); + } + + db.CaCertificates.Add(new CaCertificate + { + CommunityId = CommunityId, + ParentId = parentId, + Name = Path.GetFileNameWithoutExtension(fileName), + Subject = cert.Subject, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsed.HasPrivateKey ? parsed.RawFileBytes : null, + PfxPassword = parsed.HasPrivateKey ? password : null, + Thumbprint = thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsed.Algorithm, + KeySize = parsed.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CertSecurityLevel = CertSecurityLevel.Software, + Enabled = true + }); + } + else + { + int? issuingCaId = parsed.AuthorityKeyIdentifier != null + ? await FindCaBySkiAsync(db, parsed.AuthorityKeyIdentifier) + : null; + + // Fallback: match by Issuer DN + signature verification + issuingCaId ??= await FindCaByDnAndSignatureAsync(db, cert); + + if (issuingCaId == null) + { + // Queue for manual CA selection + pendingCaSelectQueue.Enqueue((parsed.RawFileBytes ?? Array.Empty(), fileName, password)); + parsed.Certificate.Dispose(); + return; + } + + db.IssuedCertificates.Add(new IssuedCertificate + { + IssuingCaCertificateId = issuingCaId.Value, + Name = Path.GetFileNameWithoutExtension(fileName), + Subject = cert.Subject, + SubjectAltNames = parsed.SubjectAltNames, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsed.HasPrivateKey ? parsed.RawFileBytes : null, + PfxPassword = parsed.HasPrivateKey ? password : null, + Thumbprint = thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsed.Algorithm, + KeySize = parsed.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + Enabled = true + }); + } + + await db.SaveChangesAsync(); + parsed.Certificate.Dispose(); + ToastService.ShowCopyableSuccess($"Imported '{fileName}'"); + } + catch (Exception ex) + { + importErrors.Add($"{fileName}: {ex.Message}"); + parsed.Certificate.Dispose(); + } + } + + private async Task ShowConfirmDialogAsync(ParsedCertificate parsed) + { + parsedCert = parsed; + importName = Path.GetFileNameWithoutExtension(parsed.FileName); + matchedParentCaId = null; + chainMatchDescription = null; + + // Try to find where this cert fits in the existing chain + if (parsed.DetectedRole != DetectedCertRole.RootCa && parsed.AuthorityKeyIdentifier != null) + { + await using var db = await DbFactory.CreateDbContextAsync(); + + var matchingCa = await db.CaCertificates + .Where(ca => ca.CommunityId == CommunityId) + .ToListAsync(); + + foreach (var ca in matchingCa) + { + // Load the CA cert to get its SKI + try + { + var caCert = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var skiExt = caCert.Extensions["2.5.29.14"]; + if (skiExt != null) + { + var ski = new X509SubjectKeyIdentifierExtension(skiExt, skiExt.Critical); + if (ski.SubjectKeyIdentifier == parsed.AuthorityKeyIdentifier) + { + matchedParentCaId = ca.Id; + chainMatchDescription = $"Issued by: {ca.Name} ({ca.Subject})"; + break; + } + } + caCert.Dispose(); + } + catch { } + } + + if (matchedParentCaId == null) + { + chainMatchDescription = "No matching issuer found in this community. Will be added as a root."; + } + } + + confirmDialogHidden = false; + StateHasChanged(); + } + + private async Task ConfirmImportAsync() + { + if (parsedCert == null || string.IsNullOrWhiteSpace(importName)) return; + + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var cert = parsedCert.Certificate; + + // Validate issuer relationship if a parent CA was matched + if (matchedParentCaId.HasValue) + { + var parentCaEntity = await db.CaCertificates.FindAsync(matchedParentCaId.Value); + if (parentCaEntity != null) + { + using var parentCert = X509Certificate2.CreateFromPem(parentCaEntity.X509CertificatePem); + var issuerError = CertificateIssuanceService.VerifyIssuedBy(cert, parentCert); + if (issuerError != null) + { + importError = $"Cannot link to '{parentCaEntity.Name}': {issuerError}"; + confirmDialogHidden = true; + return; + } + } + } + + if (parsedCert.DetectedRole is DetectedCertRole.RootCa or DetectedCertRole.IntermediateCa) + { + var entity = new CaCertificate + { + CommunityId = CommunityId, + ParentId = parsedCert.DetectedRole == DetectedCertRole.IntermediateCa ? matchedParentCaId : null, + Name = importName.Trim(), + Subject = cert.Subject, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsedCert.HasPrivateKey ? parsedCert.RawFileBytes : null, + PfxPassword = parsedCert.HasPrivateKey ? pfxPassword : null, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsedCert.Algorithm, + KeySize = parsedCert.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CertSecurityLevel = CertSecurityLevel.Software, + Enabled = true + }; + + db.CaCertificates.Add(entity); + } + else + { + var issuingCaId = matchedParentCaId; + + // Fallback: match by Issuer DN + signature verification + issuingCaId ??= await FindCaByDnAndSignatureAsync(db, cert); + + if (issuingCaId == null) + { + // Pass the already-parsed cert to the CA selection dialog (no re-parse needed) + confirmDialogHidden = true; + pendingCaSelectParsed = parsedCert; + parsedCert = null; // Transfer ownership, don't dispose + await ShowCaSelectDialog( + pendingCaSelectParsed.RawFileBytes, + pendingCaSelectParsed.FileName, + pendingCaSelectParsed.HasPrivateKey ? pfxPassword : null); + return; + } + + var entity = new IssuedCertificate + { + IssuingCaCertificateId = issuingCaId.Value, + Name = importName.Trim(), + Subject = cert.Subject, + SubjectAltNames = parsedCert.SubjectAltNames, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsedCert.HasPrivateKey ? parsedCert.RawFileBytes : null, + PfxPassword = parsedCert.HasPrivateKey ? pfxPassword : null, + Thumbprint = cert.Thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsedCert.Algorithm, + KeySize = parsedCert.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + Enabled = true + }; + + db.IssuedCertificates.Add(entity); + } + + await db.SaveChangesAsync(); + + confirmDialogHidden = true; + showDropZone = false; + importError = null; + parsedCert?.Certificate.Dispose(); + parsedCert = null; + + ToastService.ShowCopyableSuccess($"Certificate '{importName}' imported successfully."); + await LoadCommunityTreeAsync(CommunityId); + } + catch (Exception ex) + { + importError = $"Import failed: {ex.Message}"; + confirmDialogHidden = true; + } + } + + private async Task ImportCrlAsync(byte[] crlBytes, string fileName) + { + var result = await CrlImporter.ImportCrlAsync(crlBytes, fileName, CommunityId); + + if (!result.IsSuccess) + { + importError = result.Error; + return; + } + + showDropZone = false; + var sigStatus = result.SignatureValid ? "signature valid" : "signature INVALID"; + var nextUpdateStr = result.NextUpdate.HasValue ? TimeDisplay.FormatShort(result.NextUpdate.Value) : "unknown"; + ToastService.ShowCopyableSuccess( + $"CRL #{result.CrlNumber} imported ({sigStatus}): {result.RevokedCount} revocation(s), next update {nextUpdateStr}"); + } + + private void CancelConfirmDialog() + { + confirmDialogHidden = true; + parsedCert?.Certificate.Dispose(); + parsedCert = null; + } + + private static string GetRoleBadgeColor(DetectedCertRole role) => role switch + { + DetectedCertRole.RootCa => "#2d6a4f", + DetectedCertRole.IntermediateCa => "#1976d2", + DetectedCertRole.EndEntity => "#666", + _ => "#666" + }; + + // --- CA selection for unmatched certs --- + + private async Task LoadAvailableCasAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + availableCas = await db.CaCertificates + .Where(ca => ca.CommunityId == CommunityId) + .OrderBy(ca => ca.ParentId == null ? 0 : 1) // Roots first + .ThenBy(ca => ca.Name) + .Select(ca => new CaSelectOption + { + Id = ca.Id, + Name = ca.Name, + Subject = ca.Subject, + IsRoot = ca.ParentId == null + }) + .ToListAsync(); + } + + private async Task ShowCaSelectDialog(byte[] fileBytes, string fileName, string? password) + { + await LoadAvailableCasAsync(); + + if (availableCas.Count == 0) + { + importErrors.Add($"{fileName}: No CAs exist in this community to assign under"); + return; + } + + pendingFileBytes = fileBytes; + pendingFileName = fileName; + pfxPassword = password ?? string.Empty; + selectedCaForAssignment = null; + caSelectDialogHidden = false; + StateHasChanged(); + } + + private async void ProcessNextPendingCaSelect() + { + if (pendingCaSelectQueue.Count == 0) return; + + var (bytes, name, password) = pendingCaSelectQueue.Dequeue(); + pendingCaSelectParsed = null; // Batch items need re-parse + await ShowCaSelectDialog(bytes, name, password); + } + + private async Task ConfirmCaAssignmentAsync() + { + if (selectedCaForAssignment == null) return; + + // Use pre-parsed cert if available, otherwise re-parse from bytes + var parsed = pendingCaSelectParsed; + if (parsed == null && pendingFileBytes != null) + { + var ext = Path.GetExtension(pendingFileName).ToLowerInvariant(); + parsed = ext is ".pfx" or ".p12" + ? ParsingService.Parse(pendingFileBytes, pendingFileName, pfxPassword) + : ParsingService.Parse(pendingFileBytes, pendingFileName); + } + + if (parsed == null) + { + ToastService.ShowCopyableError($"Could not parse '{pendingFileName}'"); + caSelectDialogHidden = true; + pendingCaSelectParsed = null; + ProcessNextPendingCaSelect(); + return; + } + + try + { + await SaveWithCaAssignmentAsync(parsed); + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"Import failed: {ex.Message}"); + parsed.Certificate.Dispose(); + } + pendingCaSelectParsed = null; + + caSelectDialogHidden = true; + pendingFileBytes = null; + + if (pendingCaSelectQueue.Count > 0) + { + ProcessNextPendingCaSelect(); + } + else + { + await LoadCommunityTreeAsync(CommunityId); + } + } + + private async Task SaveWithCaAssignmentAsync(ParsedCertificate parsed) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var cert = parsed.Certificate; + var thumbprint = cert.Thumbprint; + + // Check for existing cert — merge if duplicate (e.g. .cer + .pfx for same cert) + var existingCa = await db.CaCertificates + .FirstOrDefaultAsync(c => c.Thumbprint == thumbprint && c.CommunityId == CommunityId); + if (existingCa != null) + { + if (parsed.HasPrivateKey && existingCa.EncryptedPfxBytes == null) + { + existingCa.EncryptedPfxBytes = pendingFileBytes; + existingCa.PfxPassword = pfxPassword; + await db.SaveChangesAsync(); + } + parsed.Certificate.Dispose(); + ToastService.ShowCopyableSuccess($"Merged PFX into existing '{existingCa.Name}'"); + return; + } + + var existingIssued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(c => c.Thumbprint == thumbprint + && c.IssuingCaCertificate.CommunityId == CommunityId); + if (existingIssued != null) + { + if (parsed.HasPrivateKey && existingIssued.EncryptedPfxBytes == null) + { + existingIssued.EncryptedPfxBytes = pendingFileBytes; + existingIssued.PfxPassword = pfxPassword; + await db.SaveChangesAsync(); + } + parsed.Certificate.Dispose(); + ToastService.ShowCopyableSuccess($"Merged PFX into existing '{existingIssued.Name}'"); + return; + } + + // Validate the cert was actually signed by the selected CA + var selectedCaEntity = await db.CaCertificates.FindAsync(selectedCaForAssignment!.Id); + if (selectedCaEntity != null) + { + using var caCert = X509Certificate2.CreateFromPem(selectedCaEntity.X509CertificatePem); + var issuerError = CertificateIssuanceService.VerifyIssuedBy(cert, caCert); + if (issuerError != null) + { + parsed.Certificate.Dispose(); + ToastService.ShowCopyableError($"Cannot assign under '{selectedCaEntity.Name}': {issuerError}"); + return; + } + } + + // New cert — assign under selected CA + if (parsed.DetectedRole is DetectedCertRole.RootCa or DetectedCertRole.IntermediateCa) + { + db.CaCertificates.Add(new CaCertificate + { + CommunityId = CommunityId, + ParentId = selectedCaForAssignment.Id, + Name = Path.GetFileNameWithoutExtension(pendingFileName), + Subject = cert.Subject, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsed.HasPrivateKey ? pendingFileBytes : null, + PfxPassword = parsed.HasPrivateKey ? pfxPassword : null, + Thumbprint = thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsed.Algorithm, + KeySize = parsed.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + CertSecurityLevel = CertSecurityLevel.Software, + Enabled = true + }); + } + else + { + db.IssuedCertificates.Add(new IssuedCertificate + { + IssuingCaCertificateId = selectedCaForAssignment!.Id, + Name = Path.GetFileNameWithoutExtension(pendingFileName), + Subject = cert.Subject, + SubjectAltNames = parsed.SubjectAltNames, + X509CertificatePem = cert.ExportCertificatePem(), + EncryptedPfxBytes = parsed.HasPrivateKey ? pendingFileBytes : null, + PfxPassword = parsed.HasPrivateKey ? pfxPassword : null, + Thumbprint = thumbprint, + SerialNumber = cert.SerialNumber, + KeyAlgorithm = parsed.Algorithm, + KeySize = parsed.KeySize, + NotBefore = cert.NotBefore.ToUniversalTime(), + NotAfter = cert.NotAfter.ToUniversalTime(), + Enabled = true + }); + } + + await db.SaveChangesAsync(); + parsed.Certificate.Dispose(); + ToastService.ShowCopyableSuccess($"'{Path.GetFileNameWithoutExtension(pendingFileName)}' assigned under '{selectedCaForAssignment!.Name}'"); + } + + private void SkipCaAssignment() + { + caSelectDialogHidden = true; + importErrors.Add($"{pendingFileName}: Skipped (no CA selected)"); + pendingFileBytes = null; + ProcessNextPendingCaSelect(); + } + + private void CancelCaAssignment() + { + caSelectDialogHidden = true; + pendingFileBytes = null; + pendingCaSelectQueue.Clear(); + } + + // Issuer detail state + private X509Certificate2? issuerCert; + private CertificateChainNodeViewModel? issuerNode; + private Asn1Node? issuerAsn1Root; + + // Issuance constants + private static readonly SanType[] sanTypeOptions = Enum.GetValues(); + + private static string GetSanPlaceholder(SanType type) => type switch + { + SanType.Uri => "https://example.com/fhir/r4", + SanType.Dns => "example.com", + SanType.Email => "admin@example.com", + SanType.IpAddress => "192.168.1.1", + _ => "" + }; + + // --- Issuer Navigation Methods --- + + private async Task UpdateTreeHighlightsAsync() + { + try + { + var selectedId = selectedNode != null ? $"tree-{selectedNode.Thumbprint}" : null; + var issuerId = issuerNode != null ? $"tree-{issuerNode.Thumbprint}" : null; + await JS.InvokeVoidAsync("sigilHighlightTree", selectedId, issuerId); + } + catch { /* JS not ready yet */ } + } + + private string GetTreeItemCrlStyle(CertificateChainNodeViewModel node) + { + if (IsError(node.Status)) + return $"color: {NodeColor(node.Status)};"; + return "font-style: italic; opacity: 0.8;"; + } + + private async Task LoadIssuerDetailsAsync() + { + if (selectedCert == null) return; + await LoadIssuerByCert(selectedCert); + } + + private async Task LoadIssuerOfIssuerDetailsAsync() + { + if (issuerCert == null || issuerCert.Subject == issuerCert.Issuer) return; + + // The current issuer becomes the selected cert, and we load its issuer + var prevIssuer = issuerCert; + + // Find the node for the current issuer and make it the primary selection + var issuerThumbprint = issuerCert.Thumbprint; + var node = FindNodeByThumbprint(treeNodes, issuerThumbprint); + if (node != null) + { + await SelectNode(node); + } + + // Now load the issuer of that cert + await LoadIssuerByCert(selectedCert!); + } + + private async Task LoadIssuerByCert(X509Certificate2 cert) + { + CloseIssuerDetails(); + + if (cert.Subject == cert.Issuer) return; // self-signed, no parent + + // Find issuer by AKI → SKI match, or by DN match + await using var db = await DbFactory.CreateDbContextAsync(); + + var akiValue = GetAkiKeyId(cert); + CaCertificate? issuerEntity = null; + + if (akiValue != null) + { + // Find CA whose SKI matches our AKI + var cas = await db.CaCertificates + .Where(ca => ca.CommunityId == CommunityId) + .ToListAsync(); + + foreach (var ca in cas) + { + try + { + using var caCert = X509Certificate2.CreateFromPem(ca.X509CertificatePem); + var ski = caCert.Extensions["2.5.29.14"]; + if (ski != null) + { + var skiHex = Convert.ToHexString(ski.RawData.AsSpan(2)); + if (string.Equals(skiHex, akiValue, StringComparison.OrdinalIgnoreCase)) + { + issuerEntity = ca; + break; + } + } + } + catch { } + } + } + + if (issuerEntity == null) + { + // Fallback: match by issuer DN + var cas = await db.CaCertificates + .Where(ca => ca.CommunityId == CommunityId && ca.Subject == cert.Issuer) + .ToListAsync(); + + issuerEntity = cas.FirstOrDefault(); + } + + if (issuerEntity == null) return; + + try + { + issuerCert = X509Certificate2.CreateFromPem(issuerEntity.X509CertificatePem); + issuerAsn1Root = Asn1Parser.ParsePem(issuerEntity.X509CertificatePem); + issuerNode = FindNodeByThumbprint(treeNodes, issuerEntity.Thumbprint); + } + catch { } + + StateHasChanged(); + await UpdateTreeHighlightsAsync(); + } + + private void CloseIssuerDetails() + { + issuerCert?.Dispose(); + issuerCert = null; + issuerNode = null; + issuerAsn1Root = null; + } + + private async Task CloseIssuerDetailsAsync() + { + CloseIssuerDetails(); + await UpdateTreeHighlightsAsync(); + } + + private static string? GetAkiKeyId(X509Certificate2 cert) + { + var akiExt = cert.Extensions["2.5.29.35"]; + if (akiExt == null || akiExt.RawData.Length < 6) return null; + + // AKI is a SEQUENCE containing [0] KeyIdentifier (tag 0x80) + // Simple parse: skip SEQUENCE header, look for tag 0x80 + var data = akiExt.RawData; + for (int i = 0; i < data.Length - 2; i++) + { + if (data[i] == 0x80) + { + int len = data[i + 1]; + if (i + 2 + len <= data.Length) + { + return Convert.ToHexString(data.AsSpan(i + 2, len)); + } + } + } + + return null; + } + + private static CertificateChainNodeViewModel? FindNodeByThumbprint( + List nodes, string thumbprint) + { + foreach (var node in nodes) + { + if (string.Equals(node.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) + return node; + + var found = FindNodeByThumbprint(node.Children, thumbprint); + if (found != null) return found; + } + + return null; + } + + private string GetIssuerPublicKeyInfo() + { + if (issuerCert == null) return ""; + using var rsa = issuerCert.GetRSAPublicKey(); + if (rsa != null) return $"RSA {rsa.KeySize}-bit"; + using var ecdsa = issuerCert.GetECDsaPublicKey(); + if (ecdsa != null) return $"ECDSA {ecdsa.KeySize}-bit"; + return "Unknown"; + } + + // --- Issuance Methods --- + + private async Task ShowIssuanceDialog(int? issuingCaId, string? issuingCaName) + { + isRenewMode = false; + issuingCaIdForIssuance = issuingCaId; + issuingCaNameForIssuance = issuingCaName; + issuingCaNotAfter = null; + + await using var db = await DbFactory.CreateDbContextAsync(); + + // Load issuing CA's NotAfter to clamp validity + if (issuingCaId.HasValue) + { + var ca = await db.CaCertificates.FindAsync(issuingCaId.Value); + issuingCaNotAfter = ca?.NotAfter; + } + + var allTemplates = await db.CertificateTemplates + .OrderBy(t => t.CertificateType) + .ThenBy(t => t.Name) + .ToListAsync(); + + if (issuingCaId == null) + { + // Self-signed root only + availableTemplates = allTemplates + .Where(t => t.CertificateType == CertificateType.RootCa) + .ToList(); + } + else + { + // Intermediate + end-entity templates + availableTemplates = allTemplates + .Where(t => t.CertificateType != CertificateType.RootCa) + .ToList(); + } + + selectedTemplate = availableTemplates.FirstOrDefault(); + OnTemplateSelected(selectedTemplate); + + issuanceSans.Clear(); + issuancePfxPassword = string.Empty; + issuanceKeyStorage = "local"; + issuanceCertName = string.Empty; + issuanceSubjectDn = string.Empty; + isIssuing = false; + + issuanceDialogHidden = false; + } + + private void OnTemplateSelected(CertificateTemplate? template) + { + selectedTemplate = template; + if (template == null) return; + + issuanceNotBeforeNullable = DateTime.UtcNow; + var desiredNotAfter = DateTime.UtcNow.AddDays(template.ValidityDays); + // Clamp to issuing CA's expiry + if (issuingCaNotAfter.HasValue && desiredNotAfter > issuingCaNotAfter.Value) + desiredNotAfter = issuingCaNotAfter.Value; + issuanceNotAfterNullable = desiredNotAfter; + + var cdpTemplate = template.CdpUrlTemplate; + if (template.IncludeCdp && string.IsNullOrWhiteSpace(cdpTemplate)) + cdpTemplate = "{BaseUrl}/crls/{CAName}.crl"; + issuanceCdpUrls = ExpandUrlTemplates(cdpTemplate) + .Select(u => new IssuanceUrlEntry { Value = u }).ToList(); + + var aiaTemplate = template.AiaUrlTemplate; + if (template.IncludeAia && string.IsNullOrWhiteSpace(aiaTemplate)) + aiaTemplate = "{BaseUrl}/certs/{CAName}.cer"; + issuanceAiaUrls = ExpandUrlTemplates(aiaTemplate) + .Select(u => new IssuanceUrlEntry { Value = u }).ToList(); + + if (isRenewMode) + { + issuanceSubjectDn = renewalSubjectDn; + issuanceSans = renewalSans.Select(s => new IssuanceSanEntry { Type = s.Type, Value = s.Value }).ToList(); + } + else + { + if (string.IsNullOrWhiteSpace(issuanceSubjectDn)) + issuanceSubjectDn = template.SubjectTemplate ?? string.Empty; + + var hasUserSans = issuanceSans.Any(s => !string.IsNullOrWhiteSpace(s.Value)); + if (!hasUserSans) + { + issuanceSans.Clear(); + if (!string.IsNullOrWhiteSpace(template.SubjectAltNameTypes)) + { + foreach (var sanType in template.SubjectAltNameTypes.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var type = sanType.Trim().ToUpperInvariant() switch + { + "URI" => SanType.Uri, + "DNS" => SanType.Dns, + "EMAIL" => SanType.Email, + "IP" => SanType.IpAddress, + _ => SanType.Uri + }; + issuanceSans.Add(new IssuanceSanEntry { Type = type, Value = string.Empty }); + } + } + } + } + } + + private void AddSanEntry() + { + issuanceSans.Add(new IssuanceSanEntry { Type = SanType.Uri, Value = string.Empty }); + } + + private void RemoveSanEntry(IssuanceSanEntry entry) + { + issuanceSans.Remove(entry); + } + + private async Task IssueCertificateAsync() + { + if (isIssuing) return; + if (selectedTemplate == null || string.IsNullOrWhiteSpace(issuanceSubjectDn)) return; + + isIssuing = true; + StateHasChanged(); + + var cdpUrls = selectedTemplate.IncludeCdp + ? issuanceCdpUrls.Where(u => !string.IsNullOrWhiteSpace(u.Value)).Select(u => u.Value).ToList() + : new List(); + var aiaUrls = selectedTemplate.IncludeAia + ? issuanceAiaUrls.Where(u => !string.IsNullOrWhiteSpace(u.Value)).Select(u => u.Value).ToList() + : new List(); + + // Ensure the issuing CA's cert and CRL are published before validating URLs + if (issuingCaIdForIssuance.HasValue) + { + await EnsureIssuerPublishedAsync(issuingCaIdForIssuance.Value); + } + + // Warn about missing CDP/AIA when the template expects them + var warnings = new List(); + if (selectedTemplate.IncludeCdp && cdpUrls.Count == 0) + warnings.Add("CRL Distribution Point (CDP) URLs are empty but the template has CDP enabled."); + if (selectedTemplate.IncludeAia && aiaUrls.Count == 0) + warnings.Add("Authority Information Access (AIA) URLs are empty but the template has AIA enabled."); + + // Validate that provided CDP and AIA URLs resolve + var unreachableUrls = await ValidateEndpointUrlsAsync(cdpUrls, aiaUrls); + foreach (var u in unreachableUrls) + warnings.Add($"{u.Url}: {u.Error}"); + + if (warnings.Count > 0) + { + var warningList = string.Join("\n", warnings.Select(w => $" \u2022 {w}")); + var dialog = await DialogService.ShowConfirmationAsync( + $"The following issue(s) were detected:\n\n{warningList}\n\nCertificates issued without valid CDP/AIA endpoints may cause chain validation failures. Continue anyway?", + "Issue Anyway", "Cancel", "Endpoint Warnings"); + var dialogResult = await dialog.Result; + if (dialogResult.Cancelled) + { + isIssuing = false; + return; + } + } + + try + { + var request = new CertificateIssuanceRequest + { + IssuingCaCertificateId = issuingCaIdForIssuance, + TemplateId = selectedTemplate.Id, + CommunityId = CommunityId, + SubjectDn = issuanceSubjectDn, + CertificateName = issuanceCertName, + SubjectAltNames = issuanceSans + .Where(s => !string.IsNullOrWhiteSpace(s.Value)) + .Select(s => new SanEntry(s.Type, s.Value)) + .ToList(), + CdpUrls = cdpUrls, + AiaUrls = aiaUrls, + NotBefore = issuanceNotBeforeNullable.HasValue ? new DateTimeOffset(issuanceNotBeforeNullable.Value, TimeSpan.Zero) : null, + NotAfter = issuanceNotAfterNullable.HasValue ? new DateTimeOffset(issuanceNotAfterNullable.Value, TimeSpan.Zero) : null, + PfxPassword = issuancePfxPassword, + SigningProviderOverride = issuanceKeyStorage, + }; + + var result = await IssuanceService.IssueCertificateAsync(request); + + if (result.Success) + { + // Generate initial CRL for newly issued CA certificates + if (result.EntityType == "CaCertificate" && result.EntityId.HasValue) + { + var crlResult = await CrlGenService.GenerateCrlAsync(result.EntityId.Value); + if (!crlResult.IsSuccess) + ToastService.ShowWarning($"Initial CRL generation failed: {crlResult.Error}"); + } + + issuanceDialogHidden = true; + ToastService.ShowCopyableSuccess($"Certificate issued: {result.Thumbprint}"); + await LoadCommunityTreeAsync(CommunityId); + } + else + { + ToastService.ShowCopyableError(result.Error ?? "Unknown error"); + } + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"Issuance failed: {ex.Message}"); + } + finally + { + isIssuing = false; + StateHasChanged(); + } + } + + private async Task> ValidateEndpointUrlsAsync(List cdpUrls, List aiaUrls) + { + var unreachable = new List<(string Url, string Error)>(); + var urlsToCheck = new List<(string Url, string Label)>(); + + foreach (var url in cdpUrls) + urlsToCheck.Add((url, "CDP")); + foreach (var url in aiaUrls) + urlsToCheck.Add((url, "AIA")); + + if (urlsToCheck.Count == 0) return unreachable; + + using var httpClient = HttpClientFactory.CreateClient("SigilCrl"); + httpClient.Timeout = TimeSpan.FromSeconds(5); + + var tasks = urlsToCheck.Select(async entry => + { + try + { + using var response = await httpClient.SendAsync( + new HttpRequestMessage(HttpMethod.Head, entry.Url), + HttpCompletionOption.ResponseHeadersRead); + + if (!response.IsSuccessStatusCode) + { + return (entry.Url, Error: $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + return (entry.Url, Error: (string?)null)!; + } + catch (TaskCanceledException) + { + return (entry.Url, Error: "Connection timed out"); + } + catch (HttpRequestException ex) + { + return (entry.Url, Error: ex.InnerException?.Message ?? ex.Message); + } + }); + + var results = await Task.WhenAll(tasks); + unreachable.AddRange(results.Where(r => r.Error != null)!); + + return unreachable; + } + + private async Task ShowSimilarDialog() + { + if (selectedNode == null || selectedCert == null) return; + + renewalSubjectDn = selectedCert.Subject; + renewalSans.Clear(); + foreach (var san in subjectAltNames) + { + var trimmed = san.Trim(); + if (TryParseSan(trimmed, "URL=", SanType.Uri, out var entry) || + TryParseSan(trimmed, "URI:", SanType.Uri, out entry) || + TryParseSan(trimmed, "Uri:", SanType.Uri, out entry) || + TryParseSan(trimmed, "DNS Name=", SanType.Dns, out entry) || + TryParseSan(trimmed, "DNS:", SanType.Dns, out entry) || + TryParseSan(trimmed, "Dns:", SanType.Dns, out entry) || + TryParseSan(trimmed, "RFC822 Name=", SanType.Email, out entry) || + TryParseSan(trimmed, "email:", SanType.Email, out entry) || + TryParseSan(trimmed, "Email:", SanType.Email, out entry) || + TryParseSan(trimmed, "IP Address=", SanType.IpAddress, out entry) || + TryParseSan(trimmed, "IP:", SanType.IpAddress, out entry) || + TryParseSan(trimmed, "IpAddress:", SanType.IpAddress, out entry)) + { + renewalSans.Add(entry); + } + } + + int? issuingCaId = null; + string? issuingCaName = null; + + await using var db = await DbFactory.CreateDbContextAsync(); + + if (selectedNode.CertificateRole == "EndEntity") + { + var issued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(i => i.Thumbprint == selectedNode.Thumbprint); + if (issued != null) + { + issuingCaId = issued.IssuingCaCertificateId; + issuingCaName = issued.IssuingCaCertificate.Name; + } + } + else if (selectedNode.CertificateRole == "IntermediateCA") + { + var ca = await db.CaCertificates + .Include(c => c.Parent) + .FirstOrDefaultAsync(c => c.Thumbprint == selectedNode.Thumbprint); + if (ca?.Parent != null) + { + issuingCaId = ca.ParentId; + issuingCaName = ca.Parent.Name; + } + } + else if (selectedNode.CertificateRole == "RootCA") + { + issuingCaId = null; + issuingCaName = null; + } + + isRenewMode = true; + await ShowIssuanceDialog(issuingCaId, issuingCaName); + isRenewMode = true; + + var targetType = selectedNode.CertificateRole switch + { + "RootCA" => CertificateType.RootCa, + "IntermediateCA" => CertificateType.IntermediateCa, + "EndEntity" => CertificateType.EndEntityClient, + _ => CertificateType.EndEntityClient + }; + + if (selectedNode.CertificateRole == "EndEntity") + { + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued?.TemplateId != null) + { + var match = availableTemplates.FirstOrDefault(t => t.Id == issued.TemplateId); + if (match != null) + { + selectedTemplate = match; + OnTemplateSelected(match); + } + } + } + + if (selectedTemplate == null || selectedTemplate.CertificateType != targetType) + { + var match = availableTemplates.FirstOrDefault(t => t.CertificateType == targetType) + ?? availableTemplates.FirstOrDefault(); + if (match != null) + { + selectedTemplate = match; + } + } + + OnTemplateSelected(selectedTemplate); + + issuanceCertName = selectedNode.Name; + + var cdpUrls = ExtractExtensionUrls(selectedCert, "2.5.29.31"); + if (cdpUrls.Count > 0) + issuanceCdpUrls = cdpUrls.Select(u => new IssuanceUrlEntry { Value = u }).ToList(); + + var aiaUrls = ExtractExtensionUrls(selectedCert, "1.3.6.1.5.5.7.1.1"); + if (aiaUrls.Count > 0) + issuanceAiaUrls = aiaUrls.Select(u => new IssuanceUrlEntry { Value = u }).ToList(); + + isRenewMode = false; + } + + private static List ExtractExtensionUrls(X509Certificate2? cert, string oid) + { + var urls = new List(); + if (cert == null) return urls; + + var ext = cert.Extensions[oid]; + if (ext == null) return urls; + + var data = ext.RawData; + for (int i = 0; i < data.Length - 2; i++) + { + if (data[i] == 0x86) + { + var len = data[i + 1]; + if (i + 2 + len <= data.Length) + { + urls.Add(System.Text.Encoding.ASCII.GetString(data, i + 2, len)); + i += 1 + len; + } + } + } + + return urls; + } + + private async Task ShowRenewDialog() + { + if (selectedNode == null || selectedCert == null) return; + + // Extract SANs and subject from existing cert BEFORE opening the dialog, + // so they survive template selection changes + renewalSubjectDn = selectedCert.Subject; + renewalSans.Clear(); + foreach (var san in subjectAltNames) + { + var trimmed = san.Trim(); + if (TryParseSan(trimmed, "URL=", SanType.Uri, out var entry) || + TryParseSan(trimmed, "URI:", SanType.Uri, out entry) || + TryParseSan(trimmed, "Uri:", SanType.Uri, out entry) || + TryParseSan(trimmed, "DNS Name=", SanType.Dns, out entry) || + TryParseSan(trimmed, "DNS:", SanType.Dns, out entry) || + TryParseSan(trimmed, "Dns:", SanType.Dns, out entry) || + TryParseSan(trimmed, "RFC822 Name=", SanType.Email, out entry) || + TryParseSan(trimmed, "email:", SanType.Email, out entry) || + TryParseSan(trimmed, "Email:", SanType.Email, out entry) || + TryParseSan(trimmed, "IP Address=", SanType.IpAddress, out entry) || + TryParseSan(trimmed, "IP:", SanType.IpAddress, out entry) || + TryParseSan(trimmed, "IpAddress:", SanType.IpAddress, out entry)) + { + renewalSans.Add(entry); + } + } + + // Determine the issuing CA + int? issuingCaId = null; + string? issuingCaName = null; + + await using var db = await DbFactory.CreateDbContextAsync(); + + if (selectedNode.CertificateRole == "EndEntity") + { + var issued = await db.IssuedCertificates + .Include(i => i.IssuingCaCertificate) + .FirstOrDefaultAsync(i => i.Thumbprint == selectedNode.Thumbprint); + if (issued != null) + { + issuingCaId = issued.IssuingCaCertificateId; + issuingCaName = issued.IssuingCaCertificate.Name; + } + } + else if (selectedNode.CertificateRole == "IntermediateCA") + { + var ca = await db.CaCertificates + .Include(c => c.Parent) + .FirstOrDefaultAsync(c => c.Thumbprint == selectedNode.Thumbprint); + if (ca?.Parent != null) + { + issuingCaId = ca.ParentId; + issuingCaName = ca.Parent.Name; + } + } + else if (selectedNode.CertificateRole == "RootCA") + { + issuingCaId = null; + issuingCaName = null; + } + + // Set renewal mode BEFORE opening the dialog so OnTemplateSelected preserves SANs + isRenewMode = true; + await ShowIssuanceDialog(issuingCaId, issuingCaName); + isRenewMode = true; // ShowIssuanceDialog resets it — restore + + // Pre-select a template matching the original cert's role + var targetType = selectedNode.CertificateRole switch + { + "RootCA" => CertificateType.RootCa, + "IntermediateCA" => CertificateType.IntermediateCa, + "EndEntity" => CertificateType.EndEntityClient, + _ => CertificateType.EndEntityClient + }; + + // Try to match the original cert's template if it had one + if (selectedNode.CertificateRole == "EndEntity") + { + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued?.TemplateId != null) + { + var match = availableTemplates.FirstOrDefault(t => t.Id == issued.TemplateId); + if (match != null) + { + selectedTemplate = match; + OnTemplateSelected(match); + } + } + } + + // Fallback: match by cert type + if (selectedTemplate == null || selectedTemplate.CertificateType != targetType) + { + var match = availableTemplates.FirstOrDefault(t => t.CertificateType == targetType) + ?? availableTemplates.FirstOrDefault(); + if (match != null) + { + selectedTemplate = match; + } + } + + // Always call OnTemplateSelected with isRenewMode=true to restore subject/SANs + OnTemplateSelected(selectedTemplate); + + issuanceCertName = selectedNode.Name + " (renewed)"; + } + + // --- Re-sign Methods --- + + private async Task ShowResignDialog() + { + if (selectedNode == null || selectedCert == null) return; + + // Load parent CA's NotAfter for clamping + await using var db = await DbFactory.CreateDbContextAsync(); + DateTime? parentNotAfter = null; + var caEntity = await db.CaCertificates.FindAsync(selectedNode.Id); + if (caEntity?.ParentId != null) + { + var parent = await db.CaCertificates.FindAsync(caEntity.ParentId); + parentNotAfter = parent?.NotAfter; + } + + resignNotBefore = DateTime.UtcNow; + // Default to the same duration as the original cert + var originalDuration = selectedCert.NotAfter - selectedCert.NotBefore; + var desiredNotAfter = DateTime.UtcNow.Add(originalDuration); + + // Clamp to parent's NotAfter if applicable + if (parentNotAfter.HasValue && desiredNotAfter > parentNotAfter.Value) + desiredNotAfter = parentNotAfter.Value; + + resignNotAfter = desiredNotAfter; + resignPfxPassword = string.Empty; + isResigning = false; + resignDialogHidden = false; + } + + private async Task ResignCertificateAsync() + { + if (selectedNode == null) return; + + isResigning = true; + StateHasChanged(); + + try + { + var request = new CertificateResignRequest + { + ExistingCertificateId = selectedNode.Id, + EntityType = selectedNode.EntityType, + NotBefore = resignNotBefore.HasValue ? new DateTimeOffset(resignNotBefore.Value, TimeSpan.Zero) : null, + NotAfter = resignNotAfter.HasValue ? new DateTimeOffset(resignNotAfter.Value, TimeSpan.Zero) : null, + PfxPassword = resignPfxPassword, + }; + + var result = await IssuanceService.ResignCertificateAsync(request); + + if (result.Success) + { + resignDialogHidden = true; + ToastService.ShowCopyableSuccess($"Certificate re-signed (same key): {result.Thumbprint}"); + await LoadCommunityTreeAsync(CommunityId); + } + else + { + ToastService.ShowCopyableError(result.Error ?? "Unknown error"); + } + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"Re-sign failed: {ex.Message}"); + } + finally + { + isResigning = false; + StateHasChanged(); + } + } + + private static bool TryParseSan(string value, string prefix, SanType type, out IssuanceSanEntry entry) + { + if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + entry = new IssuanceSanEntry { Type = type, Value = value[prefix.Length..].Trim() }; + return true; + } + entry = null!; + return false; + } + + private List ExpandUrlTemplates(string? template) + { + if (string.IsNullOrWhiteSpace(template)) return new(); + + var baseUrls = selectedCommunity?.BaseUrls ?? new(); + if (baseUrls.Count == 0) + { + var result = template; + if (issuingCaNameForIssuance != null) + result = result.Replace("{CAName}", issuingCaNameForIssuance, StringComparison.OrdinalIgnoreCase); + return string.IsNullOrWhiteSpace(result) ? new() : new() { result }; + } + + var expanded = new List(); + foreach (var baseUrl in baseUrls) + { + var result = template + .Replace("{BaseUrl}", baseUrl.Url.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + if (issuingCaNameForIssuance != null) + result = result.Replace("{CAName}", issuingCaNameForIssuance, StringComparison.OrdinalIgnoreCase); + expanded.Add(result); + } + + return expanded; + } + + private static string? ExtractCrlAki(byte[] crlBytes) + { + try + { + var crlParser = new Org.BouncyCastle.X509.X509CrlParser(); + var crl = crlParser.ReadCrl(crlBytes); + var akiOctets = crl.GetExtensionValue(Org.BouncyCastle.Asn1.X509.X509Extensions.AuthorityKeyIdentifier); + if (akiOctets == null) return null; + + var aki = Org.BouncyCastle.Asn1.X509.AuthorityKeyIdentifier.GetInstance( + Org.BouncyCastle.Asn1.Asn1Object.FromByteArray(akiOctets.GetOctets())); + var keyId = aki.GetKeyIdentifier(); + return keyId != null ? Convert.ToHexString(keyId) : null; + } + catch + { + return null; + } + } + + private static string GetCertTypeLabel(CertificateType ct) => ct switch + { + CertificateType.RootCa => "Root CA", + CertificateType.IntermediateCa => "Intermediate CA", + CertificateType.EndEntityClient => "Client", + CertificateType.EndEntityServer => "Server", + _ => ct.ToString() + }; + + public class CommunityOption + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public List BaseUrls { get; set; } = new(); + } + + public class CaSelectOption + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public bool IsRoot { get; set; } + public string DisplayName => IsRoot ? $"[Root] {Name}" : $"[Intermediate] {Name}"; + } + + public class IssuanceSanEntry + { + public SanType Type { get; set; } = SanType.Uri; + public string Value { get; set; } = string.Empty; + } + + public class IssuanceUrlEntry + { + public string Value { get; set; } = string.Empty; + } + + private void ShowRevokeDialog() + { + if (selectedNode == null) return; + selectedRevokeReason = revokeReasonOptions[0]; + isRevoking = false; + revokeDialogHidden = false; + } + + private async Task RevokeCertificateAsync() + { + if (selectedNode == null) return; + + isRevoking = true; + StateHasChanged(); + + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var now = DateTime.UtcNow; + int? issuingCaId = null; + + if (selectedNode.EntityType == "CaCertificate") + { + var ca = await db.CaCertificates.FindAsync(selectedNode.Id); + if (ca != null) + { + ca.IsRevoked = true; + ca.RevokedAt = now; + ca.RevocationReason = selectedRevokeReason.Code; + issuingCaId = ca.ParentId; // parent CA's CRL should include this + await db.SaveChangesAsync(); + } + } + else + { + var issued = await db.IssuedCertificates.FindAsync(selectedNode.Id); + if (issued != null) + { + issued.IsRevoked = true; + issued.RevokedAt = now; + issued.RevocationReason = selectedRevokeReason.Code; + issuingCaId = issued.IssuingCaCertificateId; + await db.SaveChangesAsync(); + } + } + + revokeDialogHidden = true; + + // Regenerate the issuing CA's CRL to include the revoked certificate + if (issuingCaId.HasValue) + { + var crlResult = await CrlGenService.GenerateCrlAsync(issuingCaId.Value); + if (crlResult.IsSuccess) + { + ToastService.ShowCopyableSuccess( + $"Certificate '{selectedNode.Name}' revoked (reason: {selectedRevokeReason.Label}). CRL #{crlResult.CrlNumber} generated with {crlResult.RevokedCount} revocation(s)."); + } + else + { + ToastService.ShowCopyableSuccess( + $"Certificate '{selectedNode.Name}' revoked (reason: {selectedRevokeReason.Label})."); + ToastService.ShowCopyableError($"CRL regeneration failed: {crlResult.Error}"); + } + } + else + { + // Root CA revoked — no parent CRL to update + ToastService.ShowCopyableSuccess( + $"Certificate '{selectedNode.Name}' revoked (reason: {selectedRevokeReason.Label})."); + } + + await LoadCommunityTreeAsync(CommunityId); + } + catch (Exception ex) + { + ToastService.ShowCopyableError($"Revocation failed: {ex.Message}"); + } + finally + { + isRevoking = false; + StateHasChanged(); + } + } + + public record RevokeReasonOption(int Code, string Label); + + public void Dispose() + { + TimeDisplay.OnChanged -= StateHasChanged; + } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/Communities.razor b/examples/CA/Sigil.UI/Components/Pages/Communities.razor new file mode 100644 index 000000000..311d797a8 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Communities.razor @@ -0,0 +1,203 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@page "/communities" + +Sigil - Communities + +Communities + + + + PKI Communities + + + Add Community + + + + + + + + + @if (context.Enabled) + { + Yes + } + else + { + No + } + + + + + + Explore + + + Edit + + + Delete + + + + + + + Add Community + + + + Certificate Server Base URLs + @foreach (var url in newCommunityBaseUrls) + { + + + + + + + + + + + } + + Add Base URL + + + + + Create + + + Cancel + + + + + + Edit Community + + + + Certificate Server Base URLs + @foreach (var url in editCommunityBaseUrls) + { + + + + + + + + + + + } + + Add Base URL + + + + + Save + + + Cancel + + + + + + Select Folder + + + + + + App root: @appBasePath + + + +
+ @if (!string.IsNullOrEmpty(folderBrowserCurrentPath) && Directory.GetParent(folderBrowserCurrentPath) != null) + { +
+ + .. +
+ } + @foreach (var dir in folderBrowserSubdirectories) + { +
+ + @Path.GetFileName(dir) +
+ } + @if (folderBrowserSubdirectories.Count == 0) + { +
+ No subdirectories +
+ } +
+
+ + + Select + + + Cancel + + +
diff --git a/examples/CA/Sigil.UI/Components/Pages/Communities.razor.cs b/examples/CA/Sigil.UI/Components/Pages/Communities.razor.cs new file mode 100644 index 000000000..831fdf2cf --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Communities.razor.cs @@ -0,0 +1,280 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Microsoft.FluentUI.AspNetCore.Components; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using Sigil.Common.ViewModels; + +namespace Sigil.UI.Components.Pages; + +public partial class Communities +{ + [Inject] private IDbContextFactory DbFactory { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] private IDialogService DialogService { get; set; } = null!; + + private List communities = new(); + private bool addDialogHidden = true; + private string newCommunityName = string.Empty; + private string newCommunityDescription = string.Empty; + private List newCommunityBaseUrls = new(); + + // Edit dialog + private bool editDialogHidden = true; + private int editCommunityId; + private string editCommunityName = string.Empty; + private string editCommunityDescription = string.Empty; + private List editCommunityBaseUrls = new(); + + // Folder browser + private bool folderBrowserHidden = true; + private string appBasePath = Directory.GetCurrentDirectory(); + private string folderBrowserCurrentPath = string.Empty; + private string? folderBrowserSelectedSubdir; + private List folderBrowserSubdirectories = new(); + private bool folderBrowserUseRelative = true; + private BaseUrlEntry? folderBrowserTarget; + + private string folderBrowserDisplayPath + { + get + { + var selected = folderBrowserSelectedSubdir ?? folderBrowserCurrentPath; + if (string.IsNullOrEmpty(selected)) return string.Empty; + if (folderBrowserUseRelative) + return Path.GetRelativePath(appBasePath, selected); + return selected; + } + } + + protected override async Task OnInitializedAsync() + { + await LoadCommunitiesAsync(); + } + + private async Task LoadCommunitiesAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + + communities = await db.Communities + .Select(c => new CommunityViewModel + { + Id = c.Id, + Name = c.Name, + Description = c.Description, + BaseUrls = c.BaseUrls.OrderBy(bu => bu.SortOrder) + .Select(bu => new BaseUrlViewModel { Url = bu.Url, PublishingBasePath = bu.PublishingBasePath }) + .ToList(), + Enabled = c.Enabled, + CreatedAt = c.CreatedAt, + RootCaCount = c.CaCertificates.Count(ca => ca.ParentId == null), + TotalCertCount = c.CaCertificates.Count() + + c.CaCertificates.SelectMany(ca => ca.IssuedCertificates).Count() + }) + .OrderBy(c => c.Name) + .ToListAsync(); + } + + private void ShowAddDialog() + { + newCommunityName = string.Empty; + newCommunityDescription = string.Empty; + newCommunityBaseUrls = new List(); + addDialogHidden = false; + } + + private async Task AddCommunityAsync() + { + if (string.IsNullOrWhiteSpace(newCommunityName)) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + + var community = new Community + { + Name = newCommunityName.Trim(), + Description = string.IsNullOrWhiteSpace(newCommunityDescription) ? null : newCommunityDescription.Trim(), + Enabled = true + }; + + var sortOrder = 0; + foreach (var entry in newCommunityBaseUrls) + { + if (!string.IsNullOrWhiteSpace(entry.Value)) + { + community.BaseUrls.Add(new CommunityBaseUrl + { + Url = entry.Value.Trim().TrimEnd('/'), + SortOrder = sortOrder++, + PublishingBasePath = string.IsNullOrWhiteSpace(entry.PublishingBasePath) ? null : entry.PublishingBasePath.Trim() + }); + } + } + + db.Communities.Add(community); + await db.SaveChangesAsync(); + addDialogHidden = true; + await LoadCommunitiesAsync(); + } + + private async Task DeleteCommunityAsync(CommunityViewModel community) + { + var dialog = await DialogService.ShowConfirmationAsync( + $"Delete community '{community.Name}' and all its certificates?", + "Delete", "Cancel", "Confirm Delete"); + var result = await dialog.Result; + + if (!result.Cancelled) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Communities.FindAsync(community.Id); + if (entity != null) + { + db.Communities.Remove(entity); + await db.SaveChangesAsync(); + } + + await LoadCommunitiesAsync(); + } + } + + private void ShowEditDialog(CommunityViewModel community) + { + editCommunityId = community.Id; + editCommunityName = community.Name; + editCommunityDescription = community.Description ?? string.Empty; + editCommunityBaseUrls = community.BaseUrls + .Select(u => new BaseUrlEntry { Value = u.Url, PublishingBasePath = u.PublishingBasePath ?? string.Empty }) + .ToList(); + editDialogHidden = false; + } + + private async Task SaveEditAsync() + { + if (string.IsNullOrWhiteSpace(editCommunityName)) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Communities + .Include(c => c.BaseUrls) + .FirstOrDefaultAsync(c => c.Id == editCommunityId); + + if (entity != null) + { + entity.Name = editCommunityName.Trim(); + entity.Description = string.IsNullOrWhiteSpace(editCommunityDescription) ? null : editCommunityDescription.Trim(); + + entity.BaseUrls.Clear(); + var sortOrder = 0; + foreach (var entry in editCommunityBaseUrls) + { + if (!string.IsNullOrWhiteSpace(entry.Value)) + { + entity.BaseUrls.Add(new CommunityBaseUrl + { + Url = entry.Value.Trim().TrimEnd('/'), + SortOrder = sortOrder++, + PublishingBasePath = string.IsNullOrWhiteSpace(entry.PublishingBasePath) ? null : entry.PublishingBasePath.Trim() + }); + } + } + + await db.SaveChangesAsync(); + } + + editDialogHidden = true; + await LoadCommunitiesAsync(); + } + + private void NavigateToExplorer(int communityId) + { + Navigation.NavigateTo($"/explorer/{communityId}"); + } + + // --- Folder Browser --- + + private void ShowFolderBrowser(BaseUrlEntry target) + { + folderBrowserTarget = target; + folderBrowserUseRelative = true; + + if (!string.IsNullOrWhiteSpace(target.PublishingBasePath)) + { + var resolved = Path.IsPathRooted(target.PublishingBasePath) + ? target.PublishingBasePath + : Path.GetFullPath(Path.Combine(appBasePath, target.PublishingBasePath)); + folderBrowserCurrentPath = Directory.Exists(resolved) ? resolved : appBasePath; + } + else + { + folderBrowserCurrentPath = appBasePath; + } + + folderBrowserSelectedSubdir = null; + LoadSubdirectories(); + folderBrowserHidden = false; + } + + private void LoadSubdirectories() + { + try + { + folderBrowserSubdirectories = Directory.GetDirectories(folderBrowserCurrentPath) + .OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + folderBrowserSubdirectories = new(); + } + } + + private void FolderBrowserNavigateUp() + { + var parent = Directory.GetParent(folderBrowserCurrentPath); + if (parent != null) + { + folderBrowserCurrentPath = parent.FullName; + folderBrowserSelectedSubdir = null; + LoadSubdirectories(); + } + } + + private void FolderBrowserSelectDir(string dir) + { + folderBrowserSelectedSubdir = dir; + } + + private void FolderBrowserNavigateInto(string dir) + { + folderBrowserCurrentPath = dir; + folderBrowserSelectedSubdir = null; + LoadSubdirectories(); + } + + private void FolderBrowserConfirm() + { + if (folderBrowserTarget == null) return; + + var selected = folderBrowserSelectedSubdir ?? folderBrowserCurrentPath; + folderBrowserTarget.PublishingBasePath = folderBrowserUseRelative + ? Path.GetRelativePath(appBasePath, selected) + : selected; + + folderBrowserHidden = true; + } + + private class BaseUrlEntry + { + public string Value { get; set; } = string.Empty; + public string PublishingBasePath { get; set; } = string.Empty; + } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/Home.razor b/examples/CA/Sigil.UI/Components/Pages/Home.razor new file mode 100644 index 000000000..515be4ace --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Home.razor @@ -0,0 +1,213 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@page "/" +@using Sigil.Common.Data + +Sigil - Dashboard + + + +Dashboard + +@if (isLoading) +{ + +} +else +{ + @* --- Quick Stats Row --- *@ + + +
@communityCount
+ Communities +
+ +
@caCertCount
+ CA Certificates +
+ +
@issuedCertCount
+ Issued Certificates +
+ +
@templateCount
+ Templates +
+ @if (revokedCertCount > 0) + { + +
@revokedCertCount
+ Revoked +
+ } +
+ + @* --- Community Health --- *@ + @if (communitySummaries.Count > 0) + { + Community Health + + @foreach (var community in communitySummaries) + { + + + @community.Name + + @if (community.IsHealthy) + { + Healthy + } + else if (community.TotalExpired > 0) + { + Action Needed + } + else + { + Warning + } + + +
+ CA Certificates + @community.CaCount + Issued Certificates + @community.IssuedCount + @if (community.TotalExpiring > 0) + { + Expiring Soon + @community.TotalExpiring + } + @if (community.TotalExpired > 0) + { + Expired + @community.TotalExpired + } + @if (community.OverdueCrlCount > 0) + { + Overdue CRLs + @community.OverdueCrlCount + } +
+
+ } +
+ } + else + { + + Getting Started +

+ No communities found. Create a community and then + import certificates or generate a new Root CA to get started. +

+
+ } + + @* --- Expiring Certificates --- *@ + @if (expiringCerts.Count > 0) + { + + + + Expiring Soon (@expiringCerts.Count) + + + + + @context.Name + + + + + + @TimeDisplay.FormatShort(context.NotAfter) + + + + + @context.DaysRemaining d + + + + + + } + + @* --- Expired Certificates --- *@ + @if (expiredCerts.Count > 0) + { + + + + Expired Certificates (@expiredCerts.Count) + + + + + @context.Name + + + + + @TimeDisplay.FormatShort(context.NotAfter) + + + + @(Math.Abs(context.DaysRemaining)) d ago + + + + + + } + + @* --- Overdue CRLs --- *@ + @if (overdueCrls.Count > 0) + { + + + + Overdue CRLs (@overdueCrls.Count) + + + + + #@context.CrlNumber + + + + + + @TimeDisplay.FormatShort(context.NextUpdate) + + + + @context.DaysOverdue d + + + + + } + + @* --- All Clear --- *@ + @if (communitySummaries.Count > 0 && expiringCerts.Count == 0 && expiredCerts.Count == 0 && overdueCrls.Count == 0) + { + + + All certificates are current +

No expiring certificates or overdue CRLs.

+
+ } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/Home.razor.cs b/examples/CA/Sigil.UI/Components/Pages/Home.razor.cs new file mode 100644 index 000000000..73f2680a0 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Home.razor.cs @@ -0,0 +1,239 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Sigil.Common.Data; + +namespace Sigil.UI.Components.Pages; + +public partial class Home : IDisposable +{ + [Inject] private IDbContextFactory DbFactory { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] private Services.TimeDisplayService TimeDisplay { get; set; } = null!; + + private bool isLoading = true; + + // Summary stats + private int communityCount; + private int caCertCount; + private int issuedCertCount; + private int templateCount; + + // Community summaries + private List communitySummaries = new(); + + // Expiring / expired certs + private List expiringCerts = new(); + private List expiredCerts = new(); + + // Overdue CRLs + private List overdueCrls = new(); + + // Revoked certs + private int revokedCertCount; + + protected override async Task OnInitializedAsync() + { + TimeDisplay.OnChanged += StateHasChanged; + await LoadDashboardAsync(); + } + + public void Dispose() + { + TimeDisplay.OnChanged -= StateHasChanged; + } + + private async Task LoadDashboardAsync() + { + isLoading = true; + StateHasChanged(); + + await using var db = await DbFactory.CreateDbContextAsync(); + var now = DateTime.UtcNow; + var expiringSoonThreshold = now.AddDays(60); + + // Counts + communityCount = await db.Communities.CountAsync(); + caCertCount = await db.CaCertificates.CountAsync(); + issuedCertCount = await db.IssuedCertificates.CountAsync(); + templateCount = await db.CertificateTemplates.CountAsync(); + + // Community summaries + communitySummaries = await db.Communities + .Select(c => new CommunitySummary + { + Id = c.Id, + Name = c.Name, + CaCount = c.CaCertificates.Count, + IssuedCount = c.CaCertificates.SelectMany(ca => ca.IssuedCertificates).Count(), + ExpiredCaCount = c.CaCertificates.Count(ca => ca.NotAfter <= now), + ExpiredIssuedCount = c.CaCertificates + .SelectMany(ca => ca.IssuedCertificates) + .Count(i => i.NotAfter <= now), + ExpiringCaCount = c.CaCertificates + .Count(ca => ca.NotAfter > now && ca.NotAfter <= expiringSoonThreshold), + ExpiringIssuedCount = c.CaCertificates + .SelectMany(ca => ca.IssuedCertificates) + .Count(i => i.NotAfter > now && i.NotAfter <= expiringSoonThreshold), + OverdueCrlCount = c.CaCertificates + .SelectMany(ca => ca.Crls) + .Count(crl => crl.NextUpdate < now), + }) + .OrderBy(c => c.Name) + .ToListAsync(); + + // Expiring certs (within 60 days, not yet expired) + var expiringCas = await db.CaCertificates + .Where(c => c.NotAfter > now && c.NotAfter <= expiringSoonThreshold && !c.IsArchived) + .Select(c => new CertRow + { + Name = c.Name, + Subject = c.Subject, + Thumbprint = c.Thumbprint, + NotAfter = c.NotAfter, + CommunityName = c.Community.Name, + CommunityId = c.CommunityId, + CertType = "CA", + DaysRemaining = (int)(c.NotAfter - now).TotalDays + }) + .ToListAsync(); + + var expiringIssued = await db.IssuedCertificates + .Where(i => i.NotAfter > now && i.NotAfter <= expiringSoonThreshold && !i.IsRevoked && !i.IsArchived) + .Select(i => new CertRow + { + Name = i.Name, + Subject = i.Subject, + Thumbprint = i.Thumbprint, + NotAfter = i.NotAfter, + CommunityName = i.IssuingCaCertificate.Community.Name, + CommunityId = i.IssuingCaCertificate.CommunityId, + CertType = "End Entity", + DaysRemaining = (int)(i.NotAfter - now).TotalDays + }) + .ToListAsync(); + + expiringCerts = expiringCas.Concat(expiringIssued) + .OrderBy(c => c.NotAfter) + .ToList(); + + // Expired certs (most recent 20) + var expiredCas = await db.CaCertificates + .Where(c => c.NotAfter <= now && !c.IsArchived) + .Select(c => new CertRow + { + Name = c.Name, + Subject = c.Subject, + Thumbprint = c.Thumbprint, + NotAfter = c.NotAfter, + CommunityName = c.Community.Name, + CommunityId = c.CommunityId, + CertType = "CA", + DaysRemaining = (int)(c.NotAfter - now).TotalDays + }) + .ToListAsync(); + + var expiredIssued = await db.IssuedCertificates + .Where(i => i.NotAfter <= now && !i.IsRevoked && !i.IsArchived) + .Select(i => new CertRow + { + Name = i.Name, + Subject = i.Subject, + Thumbprint = i.Thumbprint, + NotAfter = i.NotAfter, + CommunityName = i.IssuingCaCertificate.Community.Name, + CommunityId = i.IssuingCaCertificate.CommunityId, + CertType = "End Entity", + DaysRemaining = (int)(i.NotAfter - now).TotalDays + }) + .ToListAsync(); + + expiredCerts = expiredCas.Concat(expiredIssued) + .OrderByDescending(c => c.NotAfter) + .Take(20) + .ToList(); + + // Overdue CRLs + overdueCrls = await db.Crls + .Where(c => c.NextUpdate < now && !c.IsArchived) + .Select(c => new CrlRow + { + CrlNumber = c.CrlNumber, + CaName = c.CaCertificate.Name, + CaThumbprint = c.CaCertificate.Thumbprint, + CommunityName = c.CaCertificate.Community.Name, + CommunityId = c.CaCertificate.CommunityId, + NextUpdate = c.NextUpdate, + DaysOverdue = (int)(now - c.NextUpdate).TotalDays + }) + .OrderByDescending(c => c.DaysOverdue) + .ToListAsync(); + + // Revoked count + revokedCertCount = await db.IssuedCertificates.CountAsync(i => i.IsRevoked); + + isLoading = false; + StateHasChanged(); + } + + private void NavigateToCommunity(int communityId) + { + Navigation.NavigateTo($"/explorer/{communityId}"); + } + + private void NavigateToCert(int communityId, string thumbprint) + { + Navigation.NavigateTo($"/explorer/{communityId}?thumbprint={thumbprint}"); + } + + // --- View Models --- + + private class CommunitySummary + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int CaCount { get; set; } + public int IssuedCount { get; set; } + public int ExpiredCaCount { get; set; } + public int ExpiredIssuedCount { get; set; } + public int ExpiringCaCount { get; set; } + public int ExpiringIssuedCount { get; set; } + public int OverdueCrlCount { get; set; } + public int TotalCerts => CaCount + IssuedCount; + public int TotalExpired => ExpiredCaCount + ExpiredIssuedCount; + public int TotalExpiring => ExpiringCaCount + ExpiringIssuedCount; + public bool IsHealthy => TotalExpired == 0 && TotalExpiring == 0 && OverdueCrlCount == 0; + } + + private class CertRow + { + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Thumbprint { get; set; } = string.Empty; + public DateTime NotAfter { get; set; } + public string CommunityName { get; set; } = string.Empty; + public int CommunityId { get; set; } + public string CertType { get; set; } = string.Empty; + public int DaysRemaining { get; set; } + } + + private class CrlRow + { + public long CrlNumber { get; set; } + public string CaName { get; set; } = string.Empty; + public string CaThumbprint { get; set; } = string.Empty; + public string CommunityName { get; set; } = string.Empty; + public int CommunityId { get; set; } + public DateTime NextUpdate { get; set; } + public int DaysOverdue { get; set; } + } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/Import.razor b/examples/CA/Sigil.UI/Components/Pages/Import.razor new file mode 100644 index 000000000..a93c0c45b --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Import.razor @@ -0,0 +1,102 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@page "/import" + +Sigil - Import Certificates + +Import Certificates + + + Source Directory + + + + + Scan + + + + +@if (previews.Count > 0) +{ + + + Preview + + + Import All + + + + + + + + + + + @if (context.IsValid) + { + Ready + } + else + { + + @($"{context.Errors.Count} error(s)") + + } + + + + Import + + + + +} + +@if (importResults.Count > 0) +{ + + Import Results +
+ @foreach (var result in importResults) + { +
+ + @result.community + + @result.imported item(s) imported + @if (result.errors.Count > 0) + { +
    + @foreach (var error in result.errors) + { +
  • @error
  • + } +
+ } +
+ } +
+
+} diff --git a/examples/CA/Sigil.UI/Components/Pages/Import.razor.cs b/examples/CA/Sigil.UI/Components/Pages/Import.razor.cs new file mode 100644 index 000000000..beb51cac6 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Import.razor.cs @@ -0,0 +1,101 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.AspNetCore.Components; +using Sigil.Common.Services; +using Sigil.Common.ViewModels; + +namespace Sigil.UI.Components.Pages; + +public partial class Import +{ + [Inject] private CertificateImportService ImportService { get; set; } = null!; + + private string certstorePath = string.Empty; + private string pfxPassword = "udap-test"; + private bool isScanning; + private bool isImporting; + private List previews = new(); + private List<(string community, int imported, List errors)> importResults = new(); + + protected override void OnInitialized() + { + // Default path to the PKI generator certstores + var basePath = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", + "_tests", "Udap.PKI.Generator", "certstores")); + + if (Directory.Exists(basePath)) + { + certstorePath = basePath; + } + } + + private async Task ScanAsync() + { + isScanning = true; + importResults.Clear(); + + try + { + await Task.Run(() => + { + previews = ImportService.ScanCertStore(certstorePath, pfxPassword); + }); + } + finally + { + isScanning = false; + } + } + + private async Task ImportSingleAsync(ImportPreviewViewModel preview) + { + isImporting = true; + + try + { + var (imported, errors) = await ImportService.ImportCommunityAsync( + preview.DirectoryPath, pfxPassword); + + importResults.Add((preview.CommunityName, imported, errors)); + + // Remove from preview list after import + previews.Remove(preview); + } + finally + { + isImporting = false; + } + } + + private async Task ImportAllAsync() + { + isImporting = true; + + try + { + var toImport = previews.Where(p => p.IsValid).ToList(); + foreach (var preview in toImport) + { + var (imported, errors) = await ImportService.ImportCommunityAsync( + preview.DirectoryPath, pfxPassword); + + importResults.Add((preview.CommunityName, imported, errors)); + previews.Remove(preview); + StateHasChanged(); + } + } + finally + { + isImporting = false; + } + } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/Jobs.razor b/examples/CA/Sigil.UI/Components/Pages/Jobs.razor new file mode 100644 index 000000000..fd9792fd2 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Jobs.razor @@ -0,0 +1,117 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@page "/jobs" +@using Sigil.Common.Data +@using Sigil.Common.Services.Jobs + +Sigil - Jobs + +Jobs + +@if (isLoading) +{ + +} +else +{ + @* --- Recurring Jobs --- *@ + + Recurring Jobs + + @if (recurringJobs.Count == 0) + { +

No recurring jobs registered.

+ } + else + { + + + + + + @if (context.EditableCron != context.OriginalCron) + { + Save + } + + + + + + Run Now + + + +
+ Cron examples: + */5 * * * * (every 5 min)   + 0 * * * * (hourly)   + 0 */6 * * * (every 6 hrs)   + 0 0 * * * (daily) +
+ } +
+ + @* --- CRL Status grouped by Community --- *@ + CRL Auto-Renewal Status + + @if (crlStatuses.Count == 0) + { + +

No CA certificates with signing keys found. Import or create a CA to enable CRL generation.

+
+ } + else + { + @foreach (var group in crlStatusesByCommunity) + { + + @group.Key + + + + + @if (context.HasCrl) + { + @TimeDisplay.FormatMedium(context.NextUpdate) + } + else + { + No CRL + } + + + @if (context.NeedsRenewal) + { + Renewal Due + } + else + { + Current + } + + + + + @(context.HasCrl ? "Regenerate CRL" : "Generate CRL") + + + + + } + } + + @* --- Hangfire Link --- *@ + + Hangfire Dashboard +

View detailed job execution history, retries, and scheduled jobs.

+ + Open Hangfire Dashboard + +
+} diff --git a/examples/CA/Sigil.UI/Components/Pages/Jobs.razor.cs b/examples/CA/Sigil.UI/Components/Pages/Jobs.razor.cs new file mode 100644 index 000000000..520a45274 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Jobs.razor.cs @@ -0,0 +1,149 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Sigil.Common.Data; +using Sigil.Common.Services.Jobs; + +namespace Sigil.UI.Components.Pages; + +public partial class Jobs : IDisposable +{ + [Inject] private IDbContextFactory DbFactory { get; set; } = null!; + [Inject] private IRecurringJobScheduler JobScheduler { get; set; } = null!; + [Inject] private CrlGenerationService CrlGenerationService { get; set; } = null!; + [Inject] private Services.TimeDisplayService TimeDisplay { get; set; } = null!; + + private bool isLoading = true; + private List crlStatuses = new(); + private Dictionary> crlStatusesByCommunity = new(); + private List recurringJobs = new(); + + protected override async Task OnInitializedAsync() + { + TimeDisplay.OnChanged += StateHasChanged; + LoadRecurringJobs(); + await LoadCrlStatusesAsync(); + isLoading = false; + } + + private void LoadRecurringJobs() + { + var jobs = JobScheduler.GetRecurringJobs(); + recurringJobs = jobs.Select(j => new RecurringJobRow + { + JobId = j.JobId, + OriginalCron = j.CronExpression, + EditableCron = j.CronExpression, + LastExecution = j.LastExecution, + NextExecution = j.NextExecution + }).ToList(); + } + + private void SaveCron(RecurringJobRow row) + { + JobScheduler.UpdateCronExpression(row.JobId, row.EditableCron); + row.OriginalCron = row.EditableCron; + + // Refresh to get updated next execution time + LoadRecurringJobs(); + StateHasChanged(); + } + + private void TriggerJob(RecurringJobRow row) + { + JobScheduler.TriggerJob(row.JobId); + + // Refresh to reflect the trigger + LoadRecurringJobs(); + StateHasChanged(); + } + + private async Task GenerateCrl(CrlStatusRow row) + { + row.IsGenerating = true; + StateHasChanged(); + + var result = await CrlGenerationService.GenerateCrlAsync(row.CaId); + + row.IsGenerating = false; + + // Refresh the data to show the new CRL + await LoadCrlStatusesAsync(); + StateHasChanged(); + } + + private async Task LoadCrlStatusesAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + + var cas = await db.CaCertificates + .Where(ca => !ca.IsArchived && + (ca.EncryptedPfxBytes != null || ca.StoreProviderHint != null)) + .Include(ca => ca.Community) + .Include(ca => ca.Crls.Where(c => !c.IsArchived)) + .OrderBy(ca => ca.Community.Name) + .ThenBy(ca => ca.Name) + .ToListAsync(); + + crlStatuses = cas.Select(ca => + { + var latestCrl = ca.Crls + .OrderByDescending(c => c.CrlNumber) + .FirstOrDefault(); + + return new CrlStatusRow + { + CaId = ca.Id, + CaName = ca.Name, + CommunityName = ca.Community.Name, + LatestCrlNumber = latestCrl?.CrlNumber, + NextUpdate = latestCrl?.NextUpdate ?? DateTime.MinValue, + HasCrl = latestCrl != null, + NeedsRenewal = latestCrl == null + || latestCrl.NextUpdate <= DateTime.UtcNow.AddHours(24), + RevokedCount = latestCrl?.Revocations?.Count ?? 0 + }; + }).ToList(); + + crlStatusesByCommunity = crlStatuses + .GroupBy(s => s.CommunityName) + .OrderBy(g => g.Key) + .ToDictionary(g => g.Key, g => g.ToList()); + } + + private class RecurringJobRow + { + public string JobId { get; init; } = string.Empty; + public string OriginalCron { get; set; } = string.Empty; + public string EditableCron { get; set; } = string.Empty; + public string? LastExecution { get; init; } + public string? NextExecution { get; init; } + } + + private class CrlStatusRow + { + public int CaId { get; init; } + public string CaName { get; init; } = string.Empty; + public string CommunityName { get; init; } = string.Empty; + public long? LatestCrlNumber { get; init; } + public DateTime NextUpdate { get; init; } + public bool HasCrl { get; init; } + public bool NeedsRenewal { get; init; } + public int RevokedCount { get; init; } + public bool IsGenerating { get; set; } + } + + public void Dispose() + { + TimeDisplay.OnChanged -= StateHasChanged; + } +} diff --git a/examples/CA/Sigil.UI/Components/Pages/Templates.razor b/examples/CA/Sigil.UI/Components/Pages/Templates.razor new file mode 100644 index 000000000..96530b3a8 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Templates.razor @@ -0,0 +1,195 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@page "/templates" +@using System.Security.Cryptography.X509Certificates + +Sigil - Templates + +Certificate Templates + + + + Templates + + + Add Template + + + + + + + @{ + var (bg, label) = GetTypeBadge(context.CertificateType); + } + @label + + + + @if (context.KeyAlgorithm == "ECDSA") + { + @(context.EcdsaCurve ?? "P-384") + } + else + { + @context.KeySize + } + + + + @if (context.IsPreset) + { + Preset + } + + + + Edit + + + Clone + + @if (!context.IsPreset) + { + + Delete + + } + + + + +@* Add/Edit Dialog *@ + + @(isEditing ? "Edit Template" : "Add Template") + +
+ + @* General *@ + General + + + + + + @* Key Parameters *@ + Key Parameters + + @if (editKeyAlgorithm == "RSA") + { + + + } + else + { + +
+ Signing algorithm: @GetEcdsaSigningAlgorithmLabel() +
+ } + + @* Extensions *@ + Extensions + + Key Usage +
+ @foreach (var ku in keyUsageOptions) + { + var flag = ku.Flag; + + } +
+ + + Extended Key Usage +
+ @foreach (var eku in ekuOptions) + { + var oid = eku.Oid; + + } +
+ + @if (!string.IsNullOrWhiteSpace(editEkuOids)) + { +
+ Selected: @editEkuOids +
+ } + + +
+ + +
+ @if (editIsBasicConstraintsCa) + { + + } + + + + @* Distribution *@ + Distribution + + @if (editIncludeCdp) + { + + } + + @if (editIncludeAia) + { + + } + + @* SAN Types *@ + Subject Alternative Name Types +
+ + + + +
+
+
+ + + @(isEditing ? "Save" : "Create") + + + Cancel + + +
diff --git a/examples/CA/Sigil.UI/Components/Pages/Templates.razor.cs b/examples/CA/Sigil.UI/Components/Pages/Templates.razor.cs new file mode 100644 index 000000000..949c0b077 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Pages/Templates.razor.cs @@ -0,0 +1,399 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Microsoft.FluentUI.AspNetCore.Components; +using Sigil.Common.Data; +using Sigil.Common.Data.Entities; +using Sigil.UI.Services; + +namespace Sigil.UI.Components.Pages; + +public partial class Templates +{ + [Inject] private IDbContextFactory DbFactory { get; set; } = null!; + [Inject] private IDialogService DialogService { get; set; } = null!; + [Inject] private IToastService ToastService { get; set; } = null!; + + private List templates = new(); + private bool dialogHidden = true; + private bool isEditing; + private int? editingId; + + // Form fields + private string editName = string.Empty; + private string editDescription = string.Empty; + private CertificateType editCertType = CertificateType.EndEntityClient; + private int editValidityDays = 365; + private string editKeyAlgorithm = "RSA"; + private int editKeySize = 2048; + private string editEcdsaCurve = "nistP384"; + private string editHashAlgorithm = "SHA256"; + private int editKeyUsageFlags = (int)X509KeyUsageFlags.DigitalSignature; + private bool editIsKeyUsageCritical = true; + private string editEkuOids = string.Empty; + private string editCustomEkuOids = string.Empty; + private HashSet editSelectedEkuOids = new(); + private bool editIsEkuCritical; + private bool editIsBasicConstraintsCa; + private bool editIsBasicConstraintsCritical = true; + private int? editPathLengthConstraint; + private string editSubjectTemplate = string.Empty; + private bool editIncludeCdp; + private string editCdpUrlTemplate = string.Empty; + private bool editIncludeAia; + private string editAiaUrlTemplate = string.Empty; + private bool editSanUri; + private bool editSanDns; + private bool editSanEmail; + private bool editSanIp; + + // Options for selects + private static readonly CertificateType[] certTypes = Enum.GetValues(); + private static readonly string[] keyAlgorithms = ["RSA", "ECDSA"]; + private static readonly int[] rsaKeySizes = [2048, 3072, 4096]; + private static readonly string[] ecdsaCurves = ["nistP256", "nistP384", "nistP521"]; + private static readonly string[] hashAlgorithms = ["SHA256", "SHA384", "SHA512"]; + + private static readonly (X509KeyUsageFlags Flag, string Label)[] keyUsageOptions = + [ + (X509KeyUsageFlags.DigitalSignature, "Digital Signature"), + (X509KeyUsageFlags.KeyCertSign, "Certificate Signing"), + (X509KeyUsageFlags.CrlSign, "CRL Signing"), + (X509KeyUsageFlags.KeyEncipherment, "Key Encipherment"), + (X509KeyUsageFlags.DataEncipherment, "Data Encipherment"), + (X509KeyUsageFlags.KeyAgreement, "Key Agreement"), + (X509KeyUsageFlags.NonRepudiation, "Non-Repudiation"), + ]; + + private static readonly (string Oid, string Name)[] ekuOptions = + [ + ("1.3.6.1.5.5.7.3.1", "TLS Server Authentication"), + ("1.3.6.1.5.5.7.3.2", "TLS Client Authentication"), + ("1.3.6.1.5.5.7.3.3", "Code Signing"), + ("1.3.6.1.5.5.7.3.4", "Email Protection (S/MIME)"), + ("1.3.6.1.5.5.7.3.8", "Time Stamping"), + ("1.3.6.1.5.5.7.3.9", "OCSP Signing"), + ("1.3.6.1.4.1.311.10.3.12", "Document Signing"), + ("1.3.6.1.5.5.7.3.17", "IPSEC IKE Intermediate"), + ("2.16.840.1.113730.4.1", "Netscape SGC"), + ("1.3.6.1.4.1.311.10.3.3", "Microsoft SGC"), + ]; + + protected override async Task OnInitializedAsync() + { + await LoadTemplatesAsync(); + } + + private async Task LoadTemplatesAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + templates = await db.CertificateTemplates + .OrderByDescending(t => t.IsPreset) + .ThenBy(t => t.CertificateType) + .ThenBy(t => t.Name) + .ToListAsync(); + } + + private void ShowAddDialog() + { + isEditing = false; + editingId = null; + ResetForm(); + dialogHidden = false; + } + + private void ShowEditDialog(CertificateTemplate t) + { + isEditing = true; + editingId = t.Id; + PopulateForm(t); + dialogHidden = false; + } + + private void ResetForm() + { + editName = string.Empty; + editDescription = string.Empty; + editCertType = CertificateType.EndEntityClient; + editValidityDays = 365; + editKeyAlgorithm = "RSA"; + editKeySize = 2048; + editEcdsaCurve = "nistP384"; + editHashAlgorithm = "SHA256"; + editKeyUsageFlags = (int)X509KeyUsageFlags.DigitalSignature; + editIsKeyUsageCritical = true; + editEkuOids = string.Empty; + editCustomEkuOids = string.Empty; + editSelectedEkuOids.Clear(); + editIsEkuCritical = false; + editIsBasicConstraintsCa = false; + editIsBasicConstraintsCritical = true; + editPathLengthConstraint = null; + editSubjectTemplate = string.Empty; + editIncludeCdp = false; + editCdpUrlTemplate = string.Empty; + editIncludeAia = false; + editAiaUrlTemplate = string.Empty; + editSanUri = false; + editSanDns = false; + editSanEmail = false; + editSanIp = false; + } + + private void PopulateForm(CertificateTemplate t) + { + editName = t.Name; + editDescription = t.Description ?? string.Empty; + editCertType = t.CertificateType; + editValidityDays = t.ValidityDays; + editKeyAlgorithm = t.KeyAlgorithm; + editKeySize = t.KeySize; + editEcdsaCurve = t.EcdsaCurve ?? "nistP384"; + editHashAlgorithm = t.HashAlgorithm; + editKeyUsageFlags = t.KeyUsageFlags; + editIsKeyUsageCritical = t.IsKeyUsageCritical; + editEkuOids = t.ExtendedKeyUsageOids ?? string.Empty; + // Split stored OIDs into known (checkboxes) and custom (text field) + editSelectedEkuOids.Clear(); + editCustomEkuOids = string.Empty; + if (!string.IsNullOrWhiteSpace(editEkuOids)) + { + var knownOids = ekuOptions.Select(e => e.Oid).ToHashSet(); + var customParts = new List(); + foreach (var oid in editEkuOids.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (knownOids.Contains(oid)) + editSelectedEkuOids.Add(oid); + else + customParts.Add(oid); + } + editCustomEkuOids = string.Join(";", customParts); + } + editIsEkuCritical = t.IsExtendedKeyUsageCritical; + editIsBasicConstraintsCa = t.IsBasicConstraintsCa; + editIsBasicConstraintsCritical = t.IsBasicConstraintsCritical; + editPathLengthConstraint = t.PathLengthConstraint; + editSubjectTemplate = t.SubjectTemplate ?? string.Empty; + editIncludeCdp = t.IncludeCdp; + editCdpUrlTemplate = t.CdpUrlTemplate ?? string.Empty; + editIncludeAia = t.IncludeAia; + editAiaUrlTemplate = t.AiaUrlTemplate ?? string.Empty; + + var sanTypes = (t.SubjectAltNameTypes ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries); + editSanUri = sanTypes.Contains("URI", StringComparer.OrdinalIgnoreCase); + editSanDns = sanTypes.Contains("DNS", StringComparer.OrdinalIgnoreCase); + editSanEmail = sanTypes.Contains("Email", StringComparer.OrdinalIgnoreCase); + editSanIp = sanTypes.Contains("IP", StringComparer.OrdinalIgnoreCase); + } + + private CertificateTemplate BuildEntityFromForm(CertificateTemplate? existing = null) + { + var entity = existing ?? new CertificateTemplate(); + entity.Name = editName.Trim(); + entity.Description = string.IsNullOrWhiteSpace(editDescription) ? null : editDescription.Trim(); + entity.CertificateType = editCertType; + entity.ValidityDays = editValidityDays; + entity.KeyAlgorithm = editKeyAlgorithm; + entity.KeySize = editKeyAlgorithm == "RSA" ? editKeySize : 0; + entity.EcdsaCurve = editKeyAlgorithm == "ECDSA" ? editEcdsaCurve : null; + entity.HashAlgorithm = editHashAlgorithm; + entity.KeyUsageFlags = editKeyUsageFlags; + entity.IsKeyUsageCritical = editIsKeyUsageCritical; + entity.ExtendedKeyUsageOids = string.IsNullOrWhiteSpace(editEkuOids) ? null : editEkuOids.Trim(); + entity.IsExtendedKeyUsageCritical = editIsEkuCritical; + entity.IsBasicConstraintsCa = editIsBasicConstraintsCa; + entity.IsBasicConstraintsCritical = editIsBasicConstraintsCritical; + entity.PathLengthConstraint = editIsBasicConstraintsCa ? editPathLengthConstraint : null; + entity.SubjectTemplate = string.IsNullOrWhiteSpace(editSubjectTemplate) ? null : editSubjectTemplate.Trim(); + entity.IncludeCdp = editIncludeCdp; + entity.CdpUrlTemplate = editIncludeCdp && !string.IsNullOrWhiteSpace(editCdpUrlTemplate) ? editCdpUrlTemplate.Trim() : null; + entity.IncludeAia = editIncludeAia; + entity.AiaUrlTemplate = editIncludeAia && !string.IsNullOrWhiteSpace(editAiaUrlTemplate) ? editAiaUrlTemplate.Trim() : null; + + var sanParts = new List(); + if (editSanUri) sanParts.Add("URI"); + if (editSanDns) sanParts.Add("DNS"); + if (editSanEmail) sanParts.Add("Email"); + if (editSanIp) sanParts.Add("IP"); + entity.SubjectAltNameTypes = sanParts.Count > 0 ? string.Join(";", sanParts) : null; + + return entity; + } + + private async Task SaveTemplateAsync() + { + if (string.IsNullOrWhiteSpace(editName)) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + + if (isEditing && editingId.HasValue) + { + var entity = await db.CertificateTemplates.FindAsync(editingId.Value); + if (entity != null) + { + BuildEntityFromForm(entity); + await db.SaveChangesAsync(); + ToastService.ShowCopyableSuccess($"Template '{entity.Name}' updated."); + } + } + else + { + var entity = BuildEntityFromForm(); + db.CertificateTemplates.Add(entity); + await db.SaveChangesAsync(); + ToastService.ShowCopyableSuccess($"Template '{entity.Name}' created."); + } + + dialogHidden = true; + await LoadTemplatesAsync(); + } + + private void CloneTemplateAsync(CertificateTemplate source) + { + var clone = new CertificateTemplate + { + Name = $"Copy of {source.Name}", + Description = source.Description, + CertificateType = source.CertificateType, + KeyAlgorithm = source.KeyAlgorithm, + KeySize = source.KeySize, + ValidityDays = source.ValidityDays, + KeyUsageFlags = source.KeyUsageFlags, + IsKeyUsageCritical = source.IsKeyUsageCritical, + ExtendedKeyUsageOids = source.ExtendedKeyUsageOids, + IsExtendedKeyUsageCritical = source.IsExtendedKeyUsageCritical, + IsBasicConstraintsCa = source.IsBasicConstraintsCa, + IsBasicConstraintsCritical = source.IsBasicConstraintsCritical, + PathLengthConstraint = source.PathLengthConstraint, + EcdsaCurve = source.EcdsaCurve, + HashAlgorithm = source.HashAlgorithm, + SubjectTemplate = source.SubjectTemplate, + IncludeCdp = source.IncludeCdp, + CdpUrlTemplate = source.CdpUrlTemplate, + IncludeAia = source.IncludeAia, + AiaUrlTemplate = source.AiaUrlTemplate, + SubjectAltNameTypes = source.SubjectAltNameTypes, + IsPreset = false, + }; + + isEditing = false; + editingId = null; + PopulateForm(clone); + dialogHidden = false; + } + + private async Task DeleteTemplateAsync(CertificateTemplate template) + { + if (template.IsPreset) return; + + var dialog = await DialogService.ShowConfirmationAsync( + $"Delete template '{template.Name}'?", + "Delete", "Cancel", "Confirm Delete"); + var result = await dialog.Result; + + if (!result.Cancelled) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.CertificateTemplates.FindAsync(template.Id); + if (entity != null) + { + db.CertificateTemplates.Remove(entity); + await db.SaveChangesAsync(); + ToastService.ShowCopyableSuccess($"Template '{template.Name}' deleted."); + } + + await LoadTemplatesAsync(); + } + } + + private void ToggleKeyUsageFlag(X509KeyUsageFlags flag, bool enabled) + { + if (enabled) + editKeyUsageFlags |= (int)flag; + else + editKeyUsageFlags &= ~(int)flag; + } + + private bool IsEkuSelected(string oid) => editSelectedEkuOids.Contains(oid); + + private void ToggleEku(string oid, bool enabled) + { + if (enabled) + editSelectedEkuOids.Add(oid); + else + editSelectedEkuOids.Remove(oid); + SyncEkuOids(); + } + + private void SyncEkuOids() + { + var all = new List(editSelectedEkuOids); + + // Add any custom OIDs + if (!string.IsNullOrWhiteSpace(editCustomEkuOids)) + { + foreach (var oid in editCustomEkuOids.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!all.Contains(oid)) + all.Add(oid); + } + } + + editEkuOids = all.Count > 0 ? string.Join(";", all) : string.Empty; + } + + private void OnKeyAlgorithmChanged(string algorithm) + { + editKeyAlgorithm = algorithm; + if (algorithm == "ECDSA") + OnEcdsaCurveChanged(editEcdsaCurve); + } + + private void OnEcdsaCurveChanged(string curve) + { + editEcdsaCurve = curve; + editHashAlgorithm = curve switch + { + "nistP256" => "SHA256", + "nistP384" => "SHA384", + "nistP521" => "SHA512", + _ => "SHA384" + }; + } + + private string GetEcdsaSigningAlgorithmLabel() => editEcdsaCurve switch + { + "nistP256" => "ES256 (ECDSA P-256 + SHA-256)", + "nistP384" => "ES384 (ECDSA P-384 + SHA-384)", + "nistP521" => "ES512 (ECDSA P-521 + SHA-512)", + _ => $"ECDSA {editEcdsaCurve} + {editHashAlgorithm}" + }; + + private static string GetCertTypeLabel(CertificateType ct) => ct switch + { + CertificateType.RootCa => "Root CA", + CertificateType.IntermediateCa => "Intermediate CA", + CertificateType.EndEntityClient => "Client", + CertificateType.EndEntityServer => "Server", + _ => ct.ToString() + }; + + private static (string bg, string label) GetTypeBadge(CertificateType ct) => ct switch + { + CertificateType.RootCa => ("#8b5cf6", "Root CA"), + CertificateType.IntermediateCa => ("#3b82f6", "Intermediate CA"), + CertificateType.EndEntityClient => ("#10b981", "Client"), + CertificateType.EndEntityServer => ("#f59e0b", "Server"), + _ => ("#666", ct.ToString()) + }; +} diff --git a/examples/CA/Sigil.UI/Components/Shared/Asn1TreeNode.razor b/examples/CA/Sigil.UI/Components/Shared/Asn1TreeNode.razor new file mode 100644 index 000000000..5efb37c7c --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/Asn1TreeNode.razor @@ -0,0 +1,53 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using Sigil.Common.Services + +@if (Node != null && Node.IsConstructed && Node.Children.Count > 0) +{ + + @foreach (var child in Node.Children) + { + + } + +} +else if (Node != null) +{ + +} + +@code { + [Parameter] public Asn1Node? Node { get; set; } + + private bool expanded = true; + + private static string FormatLabel(Asn1Node node) + { + var parts = new List(); + + parts.Add(node.TagName); + + if (node.FriendlyName != null) + { + parts.Add($"({node.FriendlyName})"); + } + + if (node.Value != null) + { + var displayValue = node.Value.Length > 80 + ? node.Value[..80] + "..." + : node.Value; + parts.Add(displayValue); + } + + if (node.Length > 0 && node.TagName is "BIT STRING" or "OCTET STRING") + { + parts.Add($"[{node.Length} bytes]"); + } + + return string.Join(" ", parts); + } +} diff --git a/examples/CA/Sigil.UI/Components/Shared/Asn1TreeView.razor b/examples/CA/Sigil.UI/Components/Shared/Asn1TreeView.razor new file mode 100644 index 000000000..928ab0ec1 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/Asn1TreeView.razor @@ -0,0 +1,23 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using Sigil.Common.Services + +@if (RootNode != null) +{ +
+ + + +
+} +else +{ + No ASN.1 data to display +} + +@code { + [Parameter] public Asn1Node? RootNode { get; set; } +} diff --git a/examples/CA/Sigil.UI/Components/Shared/CaTreeNode.razor b/examples/CA/Sigil.UI/Components/Shared/CaTreeNode.razor new file mode 100644 index 000000000..73b854544 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/CaTreeNode.razor @@ -0,0 +1,81 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using Sigil.Common.ViewModels + + + + @if (Node.CertificateRole == "RootCA") + { + + } + else + { + + } + + + + @* Recursive: sub-CA nodes *@ + @foreach (var subCa in Node.Children.Where(c => c.CertificateRole is "IntermediateCA" or "RootCA")) + { + + } + + @* End-entity certs *@ + @foreach (var issued in Node.Children.Where(c => c.CertificateRole == "EndEntity")) + { + + + + + + + } + + @* CRL nodes *@ + @foreach (var crl in Node.Children.Where(c => c.CertificateRole == "CRL")) + { + + + + + + + } + + +@code { + [Parameter, EditorRequired] public CertificateChainNodeViewModel Node { get; set; } = default!; + + private static bool IsError(CertificateStatus status) => + status is CertificateStatus.Expired or CertificateStatus.Revoked or CertificateStatus.Untrusted; + + private static string NodeColor(CertificateStatus status) => status switch + { + CertificateStatus.Expired => "#e94560", + CertificateStatus.Revoked => "#9c27b0", + CertificateStatus.Untrusted => "#ff9800", + _ => "" + }; +} diff --git a/examples/CA/Sigil.UI/Components/Shared/CertBadge.razor b/examples/CA/Sigil.UI/Components/Shared/CertBadge.razor new file mode 100644 index 000000000..1de943769 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/CertBadge.razor @@ -0,0 +1,34 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using Sigil.Common.ViewModels + + + @GetLabel() + + +@code { + [Parameter] public CertificateStatus Status { get; set; } + + private string GetBackgroundColor() => Status switch + { + CertificateStatus.Valid => "#4caf50", + CertificateStatus.Expiring => "#ff9800", + CertificateStatus.Expired => "#e94560", + CertificateStatus.Revoked => "#9c27b0", + CertificateStatus.Untrusted => "#d32f2f", + _ => "#666" + }; + + private string GetLabel() => Status switch + { + CertificateStatus.Valid => "Valid", + CertificateStatus.Expiring => "Expiring", + CertificateStatus.Expired => "Expired", + CertificateStatus.Revoked => "Revoked", + CertificateStatus.Untrusted => "Untrusted", + _ => "Unknown" + }; +} diff --git a/examples/CA/Sigil.UI/Components/Shared/CopyableToast.razor b/examples/CA/Sigil.UI/Components/Shared/CopyableToast.razor new file mode 100644 index 000000000..ba748d83d --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/CopyableToast.razor @@ -0,0 +1,36 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using Microsoft.JSInterop +@implements IToastContentComponent + + + + @Content.Message + + + + +@code { + [Parameter] public CopyableToastContent Content { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = null!; + + private bool copied; + + private async Task CopyAsync() + { + try + { + await JS.InvokeVoidAsync("sigilCopyText", Content.Message); + copied = true; + StateHasChanged(); + } + catch { } + } +} diff --git a/examples/CA/Sigil.UI/Components/Shared/CrlBadge.razor b/examples/CA/Sigil.UI/Components/Shared/CrlBadge.razor new file mode 100644 index 000000000..300d28a18 --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/CrlBadge.razor @@ -0,0 +1,30 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using Sigil.Common.ViewModels + + + @GetLabel() + + +@code { + [Parameter] public CrlTimeStatus Status { get; set; } + + private string GetBackgroundColor() => Status switch + { + CrlTimeStatus.Valid => "#4caf50", + CrlTimeStatus.ExpiringSoon => "#ff9800", + CrlTimeStatus.Expired => "#e94560", + _ => "#666" + }; + + private string GetLabel() => Status switch + { + CrlTimeStatus.Valid => "Valid", + CrlTimeStatus.ExpiringSoon => "Expiring", + CrlTimeStatus.Expired => "Expired", + _ => "Unknown" + }; +} diff --git a/examples/CA/Sigil.UI/Components/Shared/ExtensionTable.razor b/examples/CA/Sigil.UI/Components/Shared/ExtensionTable.razor new file mode 100644 index 000000000..350a48ecf --- /dev/null +++ b/examples/CA/Sigil.UI/Components/Shared/ExtensionTable.razor @@ -0,0 +1,124 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using System.Security.Cryptography.X509Certificates +@using Microsoft.JSInterop + + + + + + + + + + + + + + + + + + + @foreach (var ext in extensions) + { + + + + + + + } + +
OID
Name
Critical
Value
@ext.Oid@ext.FriendlyName + @if (ext.Critical) + { + Yes + } + else + { + No + } + @ext.Value
+ +@code { + [Parameter] public X509Certificate2? Certificate { get; set; } + [Inject] private IJSRuntime JS { get; set; } = null!; + + private ElementReference tableRef; + private List extensions = new(); + private int resizingCol = -1; + private double resizeStartX; + private double resizeStartWidth; + + protected override void OnParametersSet() + { + extensions.Clear(); + + if (Certificate == null) return; + + foreach (var ext in Certificate.Extensions) + { + extensions.Add(new ExtensionRow + { + Oid = ext.Oid?.Value ?? "Unknown", + FriendlyName = ext.Oid?.FriendlyName ?? "Unknown", + Critical = ext.Critical, + Value = FormatExtensionValue(ext) + }); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("sigilColResize", tableRef); + } + } + + private void StartResize(Microsoft.AspNetCore.Components.Web.MouseEventArgs e, int colIndex) + { + // Handled by JS + } + + private static string FormatExtensionValue(X509Extension ext) + { + try + { + return ext.Format(multiLine: true); + } + catch + { + return Convert.ToHexString(ext.RawData); + } + } + + private record ExtensionRow + { + public string Oid { get; init; } = ""; + public string FriendlyName { get; init; } = ""; + public bool Critical { get; init; } + public string Value { get; init; } = ""; + } +} diff --git a/examples/CA/Sigil.UI/Components/_Imports.razor b/examples/CA/Sigil.UI/Components/_Imports.razor new file mode 100644 index 000000000..727763a2e --- /dev/null +++ b/examples/CA/Sigil.UI/Components/_Imports.razor @@ -0,0 +1,18 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Sigil.UI.Components.Layout +@using Sigil.UI.Components.Shared +@using Sigil.Common.Data +@using Sigil.Common.Data.Entities +@using Sigil.Common.Services +@using Sigil.Common.ViewModels +@using Sigil.UI.Services diff --git a/examples/CA/Sigil.UI/Services/TimeDisplayService.cs b/examples/CA/Sigil.UI/Services/TimeDisplayService.cs new file mode 100644 index 000000000..8989f4213 --- /dev/null +++ b/examples/CA/Sigil.UI/Services/TimeDisplayService.cs @@ -0,0 +1,46 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.UI.Services; + +public class TimeDisplayService +{ + public bool UseLocalTime { get; private set; } + + public event Action? OnChanged; + + public void SetUseLocalTime(bool value) + { + if (UseLocalTime == value) return; + UseLocalTime = value; + OnChanged?.Invoke(); + } + + public string Format(DateTime dt, string format = "yyyy-MM-dd HH:mm:ss") + { + var adjusted = UseLocalTime ? dt.ToLocalTime() : dt; + var suffix = UseLocalTime ? " Local" : " UTC"; + return adjusted.ToString(format) + suffix; + } + + public string FormatShort(DateTime dt) + { + var adjusted = UseLocalTime ? dt.ToLocalTime() : dt; + return adjusted.ToString("yyyy-MM-dd"); + } + + public string FormatMedium(DateTime dt) + { + var adjusted = UseLocalTime ? dt.ToLocalTime() : dt; + return adjusted.ToString("yyyy-MM-dd HH:mm"); + } + + public string Label => UseLocalTime ? "Local" : "UTC"; +} diff --git a/examples/CA/Sigil.UI/Services/ToastExtensions.cs b/examples/CA/Sigil.UI/Services/ToastExtensions.cs new file mode 100644 index 000000000..df047879c --- /dev/null +++ b/examples/CA/Sigil.UI/Services/ToastExtensions.cs @@ -0,0 +1,55 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Microsoft.FluentUI.AspNetCore.Components; +using Sigil.UI.Components.Shared; + +namespace Sigil.UI.Services; + +public class CopyableToastContent +{ + public string Message { get; set; } = string.Empty; +} + +public static class ToastExtensions +{ + public static void ShowCopyableSuccess(this IToastService toastService, string message, int timeoutMs = 5000) + { + toastService.ShowCommunicationToast(new ToastParameters + { + Intent = ToastIntent.Success, + Title = "Success", + Timeout = timeoutMs, + Content = new CommunicationToastContent { Subtitle = message }, + }); + } + + public static void ShowCopyableError(this IToastService toastService, string message, int timeoutMs = 15000) + { + toastService.ShowToast(new ToastParameters + { + Intent = ToastIntent.Error, + Title = "Error", + Timeout = timeoutMs, + Content = new CopyableToastContent { Message = message }, + }); + } + + public static void ShowCopyableWarning(this IToastService toastService, string message, int timeoutMs = 10000) + { + toastService.ShowToast(new ToastParameters + { + Intent = ToastIntent.Warning, + Title = "Warning", + Timeout = timeoutMs, + Content = new CopyableToastContent { Message = message }, + }); + } +} diff --git a/examples/CA/Sigil.UI/Sigil.UI.csproj b/examples/CA/Sigil.UI/Sigil.UI.csproj new file mode 100644 index 000000000..d91325f99 --- /dev/null +++ b/examples/CA/Sigil.UI/Sigil.UI.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/examples/CA/Sigil.Vault.Hosting/Sigil.Vault.Hosting.csproj b/examples/CA/Sigil.Vault.Hosting/Sigil.Vault.Hosting.csproj new file mode 100644 index 000000000..213b99ef0 --- /dev/null +++ b/examples/CA/Sigil.Vault.Hosting/Sigil.Vault.Hosting.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + false + + + + + + + diff --git a/examples/CA/Sigil.Vault.Hosting/TransitKeySpec.cs b/examples/CA/Sigil.Vault.Hosting/TransitKeySpec.cs new file mode 100644 index 000000000..f3467e97b --- /dev/null +++ b/examples/CA/Sigil.Vault.Hosting/TransitKeySpec.cs @@ -0,0 +1,18 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Vault.Hosting; + +/// +/// Defines a Transit signing key to be created in Vault. +/// +/// Key name in Transit, e.g. "sigil-rsa-4096" +/// Vault key type, e.g. "rsa-4096", "ecdsa-p384" +public record TransitKeySpec(string Name, string Type); diff --git a/examples/CA/Sigil.Vault.Hosting/VaultContainerImageTags.cs b/examples/CA/Sigil.Vault.Hosting/VaultContainerImageTags.cs new file mode 100644 index 000000000..609d88d49 --- /dev/null +++ b/examples/CA/Sigil.Vault.Hosting/VaultContainerImageTags.cs @@ -0,0 +1,18 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Vault.Hosting; + +public static class VaultContainerImageTags +{ + public const string Registry = "docker.io"; + public const string Image = "hashicorp/vault"; + public const string Tag = "1.19"; +} diff --git a/examples/CA/Sigil.Vault.Hosting/VaultResource.cs b/examples/CA/Sigil.Vault.Hosting/VaultResource.cs new file mode 100644 index 000000000..b17b0fdc2 --- /dev/null +++ b/examples/CA/Sigil.Vault.Hosting/VaultResource.cs @@ -0,0 +1,47 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Aspire.Hosting.ApplicationModel; + +namespace Sigil.Vault.Hosting; + +/// +/// Represents a HashiCorp Vault container resource for Aspire orchestration. +/// +public sealed class VaultResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const string HttpEndpointName = "http"; + internal const int DefaultPort = 8200; + + private EndpointReference? _primaryEndpointReference; + + /// + /// The primary HTTP endpoint for the Vault API. + /// + public EndpointReference PrimaryEndpoint => + _primaryEndpointReference ??= new EndpointReference(this, HttpEndpointName); + + /// + /// The root token used for authentication in dev mode. + /// + public string RootToken { get; set; } = "root-token"; + + /// + /// Transit key specs to be created after Vault starts. + /// + internal List TransitKeys { get; } = []; + + /// + /// Connection string expression returning the Vault HTTP address. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"); +} diff --git a/examples/CA/Sigil.Vault.Hosting/VaultResourceBuilderExtensions.cs b/examples/CA/Sigil.Vault.Hosting/VaultResourceBuilderExtensions.cs new file mode 100644 index 000000000..aaaa21cfe --- /dev/null +++ b/examples/CA/Sigil.Vault.Hosting/VaultResourceBuilderExtensions.cs @@ -0,0 +1,116 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Sigil.Vault.Hosting; + +public static class VaultResourceBuilderExtensions +{ + /// + /// Adds a HashiCorp Vault container in dev mode. + /// Dev mode starts Vault unsealed with an in-memory backend — suitable for development and testing. + /// + public static IResourceBuilder AddVaultDev( + this IDistributedApplicationBuilder builder, + string name, + string rootToken = "root-token", + int? port = null) + { + var resource = new VaultResource(name) { RootToken = rootToken }; + + var health = builder.Services.AddHealthChecks(); + + return builder.AddResource(resource) + .WithContainerName($"vault-{name}") + .WithAnnotation(new ContainerImageAnnotation + { + Image = VaultContainerImageTags.Image, + Tag = VaultContainerImageTags.Tag, + Registry = VaultContainerImageTags.Registry + }) + .WithContainerRuntimeArgs("--cap-add", "IPC_LOCK") + .WithEnvironment("VAULT_DEV_ROOT_TOKEN_ID", rootToken) + .WithEnvironment("VAULT_DEV_LISTEN_ADDRESS", "0.0.0.0:8200") + .WithEndpoint( + port ?? VaultResource.DefaultPort, + VaultResource.DefaultPort, + name: VaultResource.HttpEndpointName, + scheme: "http") + .WithHealthCheck(health, name); + } + + /// + /// Configures the Transit secrets engine with the specified signing keys. + /// Keys are created automatically after Vault starts and is healthy. + /// + public static IResourceBuilder WithTransitEngine( + this IResourceBuilder builder, + params TransitKeySpec[] keys) + { + builder.Resource.TransitKeys.AddRange(keys); + + // Subscribe to the AfterResourcesCreated event to configure Transit + builder.ApplicationBuilder.Eventing.Subscribe( + async (@event, ct) => + { + var vaultResources = @event.Model.Resources.OfType().ToList(); + + foreach (var vault in vaultResources) + { + if (vault.TransitKeys.Count == 0) continue; + + var logger = @event.Services.GetRequiredService>(); + + try + { + // Wait for the resource to be healthy + var notificationService = @event.Services + .GetRequiredService(); + await notificationService.WaitForResourceHealthyAsync( + vault.Name, ct); + + // Build the Vault address from the allocated endpoint + var endpoint = vault.GetEndpoint(VaultResource.HttpEndpointName); + var vaultAddress = $"http://localhost:{endpoint.Port}"; + + await VaultTransitConfigurator.ConfigureTransitAsync( + vaultAddress, vault.RootToken, vault.TransitKeys, logger, ct); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to configure Vault Transit for resource '{Name}'", + vault.Name); + } + } + }); + + return builder; + } + + private static IResourceBuilder WithHealthCheck( + this IResourceBuilder builder, + IHealthChecksBuilder health, + string name) + { + health.AddUrlGroup( + _ => new Uri($"http://localhost:{VaultResource.DefaultPort}/v1/sys/health"), + $"{name}-vault-health", + HealthStatus.Unhealthy); + + return builder.WithHealthCheck($"{name}-vault-health"); + } +} diff --git a/examples/CA/Sigil.Vault.Hosting/VaultTransitLifecycleHook.cs b/examples/CA/Sigil.Vault.Hosting/VaultTransitLifecycleHook.cs new file mode 100644 index 000000000..86f9c9212 --- /dev/null +++ b/examples/CA/Sigil.Vault.Hosting/VaultTransitLifecycleHook.cs @@ -0,0 +1,78 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; + +namespace Sigil.Vault.Hosting; + +/// +/// Configures the Vault Transit secrets engine after the Vault container is healthy. +/// Called via Aspire eventing (AfterResourcesCreatedEvent). +/// +internal static class VaultTransitConfigurator +{ + internal static async Task ConfigureTransitAsync( + string vaultAddress, + string rootToken, + List keys, + ILogger logger, + CancellationToken ct) + { + using var client = new HttpClient { BaseAddress = new Uri(vaultAddress) }; + client.DefaultRequestHeaders.Add("X-Vault-Token", rootToken); + + // Enable Transit secrets engine + logger.LogInformation("Enabling Vault Transit engine at {Address}", vaultAddress); + + var mountResponse = await client.PostAsJsonAsync( + "/v1/sys/mounts/transit", + new { type = "transit" }, + ct); + + if (mountResponse.IsSuccessStatusCode) + { + logger.LogInformation("Transit engine mounted successfully"); + } + else if ((int)mountResponse.StatusCode == 400) + { + // Already mounted — not an error + logger.LogInformation("Transit engine already mounted"); + } + else + { + var body = await mountResponse.Content.ReadAsStringAsync(ct); + logger.LogWarning("Transit mount returned {Status}: {Body}", + mountResponse.StatusCode, body); + } + + // Create signing keys + foreach (var key in keys) + { + logger.LogInformation("Creating Transit key '{Name}' (type: {Type})", key.Name, key.Type); + + var keyResponse = await client.PostAsJsonAsync( + $"/v1/transit/keys/{key.Name}", + new { type = key.Type }, + ct); + + if (keyResponse.IsSuccessStatusCode) + { + logger.LogInformation("Transit key '{Name}' created", key.Name); + } + else + { + var body = await keyResponse.Content.ReadAsStringAsync(ct); + logger.LogWarning("Transit key '{Name}' creation returned {Status}: {Body}", + key.Name, keyResponse.StatusCode, body); + } + } + } +} diff --git a/examples/CA/Sigil.Vault/Sigil.Vault.csproj b/examples/CA/Sigil.Vault/Sigil.Vault.csproj new file mode 100644 index 000000000..7c539281f --- /dev/null +++ b/examples/CA/Sigil.Vault/Sigil.Vault.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/examples/CA/Sigil.Vault/VaultSignatureFactory.cs b/examples/CA/Sigil.Vault/VaultSignatureFactory.cs new file mode 100644 index 000000000..5aa29935c --- /dev/null +++ b/examples/CA/Sigil.Vault/VaultSignatureFactory.cs @@ -0,0 +1,125 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Sigil.Common.Services.Signing; + +namespace Sigil.Vault; + +/// +/// BouncyCastle ISignatureFactory that delegates signing to an ISigningProvider. +/// Used with X509V3CertificateGenerator for remote signing (Vault Transit, Cloud KMS). +/// +internal sealed class VaultSignatureFactory : ISignatureFactory +{ + private readonly ISigningProvider _provider; + private readonly SigningKeyReference _keyRef; + private readonly HashAlgorithmName _hashAlgorithm; + private readonly AlgorithmIdentifier _algId; + + public VaultSignatureFactory( + ISigningProvider provider, + SigningKeyReference keyRef, + HashAlgorithmName hashAlgorithm) + { + _provider = provider; + _keyRef = keyRef; + _hashAlgorithm = hashAlgorithm; + _algId = BuildAlgorithmIdentifier(keyRef.KeyAlgorithm, hashAlgorithm); + } + + public object AlgorithmDetails => _algId; + + public IStreamCalculator CreateCalculator() + { + return new VaultStreamCalculator(_provider, _keyRef, _hashAlgorithm); + } + + private static AlgorithmIdentifier BuildAlgorithmIdentifier( + string keyAlgorithm, HashAlgorithmName hashAlgorithm) + { + // Map to X.509 signature algorithm OIDs + if (keyAlgorithm.Equals("RSA", StringComparison.OrdinalIgnoreCase)) + { + var oid = hashAlgorithm.Name switch + { + "SHA384" => Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Sha384WithRsaEncryption, + "SHA512" => Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Sha512WithRsaEncryption, + _ => Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers.Sha256WithRsaEncryption + }; + return new AlgorithmIdentifier(oid, DerNull.Instance); + } + else // ECDSA + { + var oid = hashAlgorithm.Name switch + { + "SHA384" => X9ObjectIdentifiers.ECDsaWithSha384, + "SHA512" => X9ObjectIdentifiers.ECDsaWithSha512, + _ => X9ObjectIdentifiers.ECDsaWithSha256 + }; + return new AlgorithmIdentifier(oid); + } + } + + /// + /// Stream calculator that buffers all TBS data and signs it via the provider when GetResult() is called. + /// Note: GetResult() blocks on async — this is unavoidable because BouncyCastle's API is synchronous. + /// + private sealed class VaultStreamCalculator : IStreamCalculator + { + private readonly ISigningProvider _provider; + private readonly SigningKeyReference _keyRef; + private readonly HashAlgorithmName _hashAlgorithm; + private readonly MemoryStream _buffer = new(); + + public VaultStreamCalculator( + ISigningProvider provider, + SigningKeyReference keyRef, + HashAlgorithmName hashAlgorithm) + { + _provider = provider; + _keyRef = keyRef; + _hashAlgorithm = hashAlgorithm; + } + + public Stream Stream => _buffer; + + public IBlockResult GetResult() + { + var data = _buffer.ToArray(); + // Block on async — BouncyCastle doesn't support async signing + var signature = _provider.SignDataAsync(data, _hashAlgorithm, _keyRef) + .GetAwaiter().GetResult(); + return new SimpleBlockResult(signature); + } + } + + private sealed class SimpleBlockResult : IBlockResult + { + private readonly byte[] _result; + public SimpleBlockResult(byte[] result) => _result = result; + public byte[] Collect() => _result; + public int Collect(byte[] destination, int offset) + { + Array.Copy(_result, 0, destination, offset, _result.Length); + return _result.Length; + } + public int Collect(Span destination) + { + _result.CopyTo(destination); + return _result.Length; + } + public int GetMaxResultLength() => _result.Length; + } +} diff --git a/examples/CA/Sigil.Vault/VaultTransitOptions.cs b/examples/CA/Sigil.Vault/VaultTransitOptions.cs new file mode 100644 index 000000000..3513edddd --- /dev/null +++ b/examples/CA/Sigil.Vault/VaultTransitOptions.cs @@ -0,0 +1,32 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +namespace Sigil.Vault; + +/// +/// Configuration for connecting to Vault Transit. +/// +public class VaultTransitOptions +{ + /// + /// Vault HTTP address, e.g. "http://localhost:8200". + /// + public string Address { get; set; } = "http://localhost:8200"; + + /// + /// Authentication token for Vault API calls. + /// + public string Token { get; set; } = string.Empty; + + /// + /// Mount path for the Transit engine. Defaults to "transit". + /// + public string MountPath { get; set; } = "transit"; +} diff --git a/examples/CA/Sigil.Vault/VaultTransitSigningProvider.cs b/examples/CA/Sigil.Vault/VaultTransitSigningProvider.cs new file mode 100644 index 000000000..ebc227bf7 --- /dev/null +++ b/examples/CA/Sigil.Vault/VaultTransitSigningProvider.cs @@ -0,0 +1,287 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sigil.Common.Services.Signing; + +namespace Sigil.Vault; + +/// +/// Signing provider that delegates to HashiCorp Vault's Transit secrets engine. +/// Private keys never leave Vault — only the signature is returned. +/// +public sealed class VaultTransitSigningProvider : ISigningProvider +{ + public string ProviderName => "vault-transit"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly VaultTransitOptions _options; + private readonly ILogger _logger; + + public VaultTransitSigningProvider( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _options = options.Value; + _logger = logger; + } + + public async Task GenerateKeyAsync( + string keyAlgorithm, int keySize, string? ecdsaCurve = null, + CancellationToken ct = default) + { + var vaultKeyType = MapToVaultKeyType(keyAlgorithm, keySize, ecdsaCurve); + var keyName = $"sigil-{Guid.NewGuid():N}"; + + using var client = CreateClient(); + + var response = await client.PostAsync( + $"/v1/{_options.MountPath}/keys/{keyName}", + JsonContent(new { type = vaultKeyType }), + ct); + + response.EnsureSuccessStatusCode(); + + _logger.LogInformation("Created Vault Transit key '{Name}' (type: {Type})", keyName, vaultKeyType); + + return new SigningKeyReference("vault-transit", keyName, keyAlgorithm, keySize); + } + + public async Task GetPublicKeyAsync( + SigningKeyReference keyRef, CancellationToken ct = default) + { + using var client = CreateClient(); + + var response = await client.GetAsync( + $"/v1/{_options.MountPath}/keys/{keyRef.KeyIdentifier}", + ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonDocument.Parse(json); + + // Navigate: data.keys."1".public_key + var keys = doc.RootElement.GetProperty("data").GetProperty("keys"); + var latestKey = keys.GetProperty("1"); + var publicKeyPem = latestKey.GetProperty("public_key").GetString() + ?? throw new InvalidOperationException("No public key in Vault response"); + + if (keyRef.KeyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + var ecdsa = ECDsa.Create(); + ecdsa.ImportFromPem(publicKeyPem); + return ecdsa; + } + else + { + var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + return rsa; + } + } + + public async Task SignDataAsync( + byte[] data, HashAlgorithmName hashAlgorithm, + SigningKeyReference keyRef, CancellationToken ct = default) + { + // Vault Transit expects pre-hashed data with prehashed=true, + // or raw data as base64 input + var input = Convert.ToBase64String(data); + var hashName = hashAlgorithm.Name?.ToLowerInvariant() ?? "sha2-256"; + + // Map .NET hash names to Vault hash names + var vaultHash = hashName switch + { + "sha256" => "sha2-256", + "sha384" => "sha2-384", + "sha512" => "sha2-512", + _ => "sha2-256" + }; + + var signatureAlgorithm = keyRef.KeyAlgorithm.Equals("RSA", StringComparison.OrdinalIgnoreCase) + ? "pkcs1v15" + : null; // ECDSA uses default + + var requestBody = new Dictionary + { + ["input"] = input, + ["hash_algorithm"] = vaultHash, + }; + + if (signatureAlgorithm != null) + requestBody["signature_algorithm"] = signatureAlgorithm; + + using var client = CreateClient(); + + var response = await client.PostAsync( + $"/v1/{_options.MountPath}/sign/{keyRef.KeyIdentifier}", + JsonContent(requestBody), + ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonDocument.Parse(json); + + // Response: data.signature = "vault:v1:" + var signature = doc.RootElement.GetProperty("data").GetProperty("signature").GetString() + ?? throw new InvalidOperationException("No signature in Vault response"); + + // Strip "vault:v1:" prefix + var parts = signature.Split(':'); + if (parts.Length != 3 || parts[0] != "vault") + throw new InvalidOperationException($"Unexpected Vault signature format: {signature[..Math.Min(30, signature.Length)]}..."); + + var signatureBytes = Convert.FromBase64String(parts[2]); + + // For ECDSA: Vault returns P1363 format, BouncyCastle expects DER. + // Convert if needed. + if (keyRef.KeyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + signatureBytes = ConvertP1363ToDer(signatureBytes); + } + + return signatureBytes; + } + + /// + /// Deletes a Transit key from Vault. Vault keys are deletion-protected by default, + /// so this first enables deletion via config update, then deletes. + /// + public async Task DeleteKeyAsync(string keyName, CancellationToken ct = default) + { + using var client = CreateClient(); + + // Step 1: Enable deletion (Vault protects keys by default) + var configResponse = await client.PostAsync( + $"/v1/{_options.MountPath}/keys/{keyName}/config", + JsonContent(new { deletion_allowed = true }), + ct); + + if (!configResponse.IsSuccessStatusCode) + { + var body = await configResponse.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Failed to enable deletion for Transit key '{Name}': {Status} {Body}", + keyName, configResponse.StatusCode, body); + return; + } + + // Step 2: Delete the key + var deleteResponse = await client.DeleteAsync( + $"/v1/{_options.MountPath}/keys/{keyName}", + ct); + + if (deleteResponse.IsSuccessStatusCode) + { + _logger.LogInformation("Deleted Vault Transit key '{Name}'", keyName); + } + else + { + var body = await deleteResponse.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Failed to delete Transit key '{Name}': {Status} {Body}", + keyName, deleteResponse.StatusCode, body); + } + } + + /// + /// Converts IEEE P1363 format (r || s) to DER-encoded ECDSA signature. + /// + internal static byte[] ConvertP1363ToDer(byte[] p1363Signature) + { + int halfLen = p1363Signature.Length / 2; + ReadOnlySpan r = p1363Signature.AsSpan(0, halfLen); + ReadOnlySpan s = p1363Signature.AsSpan(halfLen, halfLen); + + // Trim leading zeros but keep one if the high bit is set + r = TrimLeadingZeros(r); + s = TrimLeadingZeros(s); + + // Add leading zero byte if high bit is set (DER integer encoding) + bool rNeedsPad = (r[0] & 0x80) != 0; + bool sNeedsPad = (s[0] & 0x80) != 0; + + int rLen = r.Length + (rNeedsPad ? 1 : 0); + int sLen = s.Length + (sNeedsPad ? 1 : 0); + int seqLen = 2 + rLen + 2 + sLen; + + var der = new byte[2 + seqLen]; + int offset = 0; + + // SEQUENCE + der[offset++] = 0x30; + der[offset++] = (byte)seqLen; + + // INTEGER r + der[offset++] = 0x02; + der[offset++] = (byte)rLen; + if (rNeedsPad) der[offset++] = 0x00; + r.CopyTo(der.AsSpan(offset)); + offset += r.Length; + + // INTEGER s + der[offset++] = 0x02; + der[offset++] = (byte)sLen; + if (sNeedsPad) der[offset++] = 0x00; + s.CopyTo(der.AsSpan(offset)); + + return der; + } + + private static ReadOnlySpan TrimLeadingZeros(ReadOnlySpan value) + { + int i = 0; + while (i < value.Length - 1 && value[i] == 0) i++; + return value[i..]; + } + + private static string MapToVaultKeyType(string keyAlgorithm, int keySize, string? ecdsaCurve) + { + if (keyAlgorithm.Equals("ECDSA", StringComparison.OrdinalIgnoreCase)) + { + return ecdsaCurve?.ToLowerInvariant() switch + { + "nistp256" => "ecdsa-p256", + "nistp384" => "ecdsa-p384", + "nistp521" => "ecdsa-p521", + _ => "ecdsa-p384" + }; + } + + return keySize switch + { + 2048 => "rsa-2048", + 3072 => "rsa-3072", + 4096 => "rsa-4096", + _ => $"rsa-{keySize}" + }; + } + + private HttpClient CreateClient() + { + var client = _httpClientFactory.CreateClient("VaultTransit"); + client.BaseAddress = new Uri(_options.Address); + client.DefaultRequestHeaders.Remove("X-Vault-Token"); + client.DefaultRequestHeaders.Add("X-Vault-Token", _options.Token); + return client; + } + + private static StringContent JsonContent(object value) + { + return new StringContent( + JsonSerializer.Serialize(value), + Encoding.UTF8, + "application/json"); + } +} diff --git a/examples/CA/Sigil.slnx b/examples/CA/Sigil.slnx new file mode 100644 index 000000000..2dd88980b --- /dev/null +++ b/examples/CA/Sigil.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/examples/CA/Sigil/.gitignore b/examples/CA/Sigil/.gitignore new file mode 100644 index 000000000..89f9ac04a --- /dev/null +++ b/examples/CA/Sigil/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/examples/CA/Sigil/Components/App.razor b/examples/CA/Sigil/Components/App.razor new file mode 100644 index 000000000..b1e3bf2e4 --- /dev/null +++ b/examples/CA/Sigil/Components/App.razor @@ -0,0 +1,98 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + + + + + + + Sigil - PKI Management + + + + + + + + + + + + + diff --git a/examples/CA/Sigil/Components/Routes.razor b/examples/CA/Sigil/Components/Routes.razor new file mode 100644 index 000000000..345cec55b --- /dev/null +++ b/examples/CA/Sigil/Components/Routes.razor @@ -0,0 +1,11 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + + + + + + diff --git a/examples/CA/Sigil/Components/_Imports.razor b/examples/CA/Sigil/Components/_Imports.razor new file mode 100644 index 000000000..be1c53084 --- /dev/null +++ b/examples/CA/Sigil/Components/_Imports.razor @@ -0,0 +1,9 @@ +@* (c) 2026 Joseph Shook. All rights reserved. + Authors: Joseph Shook JoeShook@Gmail.com + Joseph.Shook@Surescripts.com + See LICENSE in the project root for license information. *@ + +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.FluentUI.AspNetCore.Components diff --git a/examples/CA/Sigil/Dockerfile b/examples/CA/Sigil/Dockerfile new file mode 100644 index 000000000..2514ab198 --- /dev/null +++ b/examples/CA/Sigil/Dockerfile @@ -0,0 +1,40 @@ +# Build context: examples/CA/ (set by DockerfileContext in Sigil.csproj) +# +# VS builds this automatically via Docker launch profile. +# Aspire uses the pre-built image via AddContainer("sigil", "sigil", "dev"). + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 5200 +EXPOSE 7200 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY Sigil/Sigil.csproj Sigil/ +COPY Sigil.Common/Sigil.Common.csproj Sigil.Common/ +COPY Sigil.UI/Sigil.UI.csproj Sigil.UI/ +COPY Sigil.ServiceDefaults/Sigil.ServiceDefaults.csproj Sigil.ServiceDefaults/ +COPY Sigil.Vault/Sigil.Vault.csproj Sigil.Vault/ +COPY Sigil.Gcp/Sigil.Gcp.csproj Sigil.Gcp/ +COPY Sigil.FileSystem/Sigil.FileSystem.csproj Sigil.FileSystem/ + +RUN dotnet restore Sigil/Sigil.csproj + +COPY Sigil/ Sigil/ +COPY Sigil.Common/ Sigil.Common/ +COPY Sigil.UI/ Sigil.UI/ +COPY Sigil.ServiceDefaults/ Sigil.ServiceDefaults/ +COPY Sigil.Vault/ Sigil.Vault/ +COPY Sigil.Gcp/ Sigil.Gcp/ +COPY Sigil.FileSystem/ Sigil.FileSystem/ + +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish Sigil/Sigil.csproj -c $BUILD_CONFIGURATION -o /app/publish + +# Generate dev cert in the SDK stage (aspnet runtime doesn't have dev-certs tool) +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "Sigil.dll"] diff --git a/examples/CA/Sigil/Dockerfile.gcp b/examples/CA/Sigil/Dockerfile.gcp new file mode 100644 index 000000000..051d4976d --- /dev/null +++ b/examples/CA/Sigil/Dockerfile.gcp @@ -0,0 +1,62 @@ +# Build context: examples/CA/ (set by DockerfileContext in Sigil.csproj) +# +# VS builds this automatically via Docker-GCP launch profile. +# Aspire uses the pre-built image via AddContainer("sigil", "sigil_gcp", "dev"). +# +# First-time GCP setup (from a second terminal): +# docker exec -it bash +# gcloud auth application-default login +# gcloud config set project +# +# Credentials persist in the "sigil-gcloud-config" named volume. + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base + +# Install gcloud CLI +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl gnupg apt-transport-https ca-certificates && \ + echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ + > /etc/apt/sources.list.d/google-cloud-sdk.list && \ + curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \ + | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \ + apt-get update && \ + apt-get install -y --no-install-recommends google-cloud-cli && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +VOLUME ["/root/.config/gcloud"] +EXPOSE 5200 +EXPOSE 7200 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY Sigil/Sigil.csproj Sigil/ +COPY Sigil.Common/Sigil.Common.csproj Sigil.Common/ +COPY Sigil.UI/Sigil.UI.csproj Sigil.UI/ +COPY Sigil.ServiceDefaults/Sigil.ServiceDefaults.csproj Sigil.ServiceDefaults/ +COPY Sigil.Vault/Sigil.Vault.csproj Sigil.Vault/ +COPY Sigil.Gcp/Sigil.Gcp.csproj Sigil.Gcp/ +COPY Sigil.FileSystem/Sigil.FileSystem.csproj Sigil.FileSystem/ + +RUN dotnet restore Sigil/Sigil.csproj + +COPY Sigil/ Sigil/ +COPY Sigil.Common/ Sigil.Common/ +COPY Sigil.UI/ Sigil.UI/ +COPY Sigil.ServiceDefaults/ Sigil.ServiceDefaults/ +COPY Sigil.Vault/ Sigil.Vault/ +COPY Sigil.Gcp/ Sigil.Gcp/ +COPY Sigil.FileSystem/ Sigil.FileSystem/ + +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish Sigil/Sigil.csproj -c $BUILD_CONFIGURATION -o /app/publish + +# Generate dev cert in the SDK stage (aspnet runtime doesn't have dev-certs tool) +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "Sigil.dll"] diff --git a/examples/CA/Sigil/Program.cs b/examples/CA/Sigil/Program.cs new file mode 100644 index 000000000..789cb3d5d --- /dev/null +++ b/examples/CA/Sigil/Program.cs @@ -0,0 +1,340 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.FluentUI.AspNetCore.Components; +using Serilog; +using Sigil.Components; +using Microsoft.Extensions.Options; +using Sigil.Common.Data; +using Sigil.ServiceDefaults; +using Sigil.Common.Data.Entities; +using Hangfire; +using Hangfire.Dashboard; +using Hangfire.PostgreSql; +using Hangfire.Storage; +using Sigil.Common.Services; +using Sigil.Common.Services.Jobs; +using Sigil.Common.Services.Signing; +using Sigil.Gcp; +using Sigil.Services; +using Sigil.Vault; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + // Aspire service defaults (active when running under Aspire AppHost) + var useServiceDefaults = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is not null; + if (useServiceDefaults) + { + builder.AddServiceDefaults(); + } + + // Serilog + builder.Host.UseSerilog((context, config) => config + .ReadFrom.Configuration(context.Configuration) + .WriteTo.Console()); + + // PostgreSQL + builder.Services.AddDbContextFactory(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("SigilDb"))); + + // Services + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHttpClient("SigilCrl"); + builder.Services.AddHttpClient(); + + // Signing provider configuration + builder.Services.Configure( + builder.Configuration.GetSection("Signing")); + builder.Services.Configure( + builder.Configuration.GetSection("Vault")); + builder.Services.Configure( + builder.Configuration.GetSection("GcpKms")); + builder.Services.AddHttpClient("VaultTransit"); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return options.Provider switch + { + "vault-transit" => sp.GetRequiredService(), + "gcp-kms" => sp.GetRequiredService(), + _ => sp.GetRequiredService() + }; + }); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Hangfire + builder.Services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(options => + options.UseNpgsqlConnection(builder.Configuration.GetConnectionString("SigilDb")), + new PostgreSqlStorageOptions { SchemaName = "sigil" })); + builder.Services.AddHangfireServer(); + builder.Services.AddSingleton(); + + // Blazor Server + Fluent UI + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + builder.Services.AddFluentUIComponents(); + + var app = builder.Build(); + + // Run migrations + seed data + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + await SeedTemplatesAsync(db); + } + + app.UseSerilogRequestLogging(options => + { + options.GetLevel = (httpContext, elapsed, ex) => + { + var path = httpContext.Request.Path.Value ?? ""; + if (path.StartsWith("/_blazor") || + path.StartsWith("/_framework") || + path.StartsWith("/_content") || + path.StartsWith("/css") || + path == "/favicon.ico") + { + return Serilog.Events.LogEventLevel.Debug; + } + + return Serilog.Events.LogEventLevel.Information; + }; + }); + + // Hangfire dashboard (Sigil is a dev/internal tool — open access) + app.MapHangfireDashboard("/hangfire", new DashboardOptions + { + Authorization = [new AllowAllDashboardAuthorizationFilter()] + }); + + // Register recurring CRL auto-renewal job only if not already configured + // (preserves cron schedule edited via the Jobs UI across restarts) + using (var connection = JobStorage.Current.GetConnection()) + { + var existingJobs = connection.GetRecurringJobs(); + if (!existingJobs.Any(j => j.Id == "crl-auto-renewal")) + { + var crlCron = builder.Configuration["Hangfire:CrlRenewalCron"] ?? Cron.Hourly(); + RecurringJob.AddOrUpdate( + "crl-auto-renewal", + job => job.ExecuteAsync(CancellationToken.None), + crlCron); + } + } + + app.UseStaticFiles(); + app.MapStaticAssets(); + app.UseAntiforgery(); + if (useServiceDefaults) + { + app.MapDefaultEndpoints(); // Aspire health checks (/health, /alive) + } + + app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies( + typeof(FluentButton).Assembly, + typeof(Sigil.UI.Components.Pages.Home).Assembly); + + // Download endpoints + app.MapGet("/api/ca/{id}/download/cer", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var ca = await db.CaCertificates.FindAsync(id); + if (ca == null) return Results.NotFound(); + + var cerBytes = Encoding.UTF8.GetBytes(ca.X509CertificatePem); + return Results.File(cerBytes, "application/x-pem-file", $"{ca.Name}.cer"); + }); + + app.MapGet("/api/ca/{id}/download/p12", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var ca = await db.CaCertificates.FindAsync(id); + if (ca?.EncryptedPfxBytes == null) return Results.NotFound("No private key available"); + + return Results.File(ca.EncryptedPfxBytes, "application/x-pkcs12", $"{ca.Name}.p12"); + }); + + app.MapGet("/api/issued/{id}/download/cer", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var cert = await db.IssuedCertificates.FindAsync(id); + if (cert == null) return Results.NotFound(); + + var cerBytes = Encoding.UTF8.GetBytes(cert.X509CertificatePem); + return Results.File(cerBytes, "application/x-pem-file", $"{cert.Name}.cer"); + }); + + app.MapGet("/api/issued/{id}/download/p12", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var cert = await db.IssuedCertificates.FindAsync(id); + if (cert?.EncryptedPfxBytes == null) return Results.NotFound("No private key available"); + + return Results.File(cert.EncryptedPfxBytes, "application/x-pkcs12", $"{cert.Name}.p12"); + }); + + app.MapGet("/api/crl/{id}/download", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var crl = await db.Crls.FindAsync(id); + if (crl == null) return Results.NotFound(); + + var fileName = crl.FileName ?? $"crl-{crl.CrlNumber}.crl"; + return Results.File(crl.RawBytes, "application/pkix-crl", fileName); + }); + + // PEM download endpoints + app.MapGet("/api/ca/{id}/download/pem", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var ca = await db.CaCertificates.FindAsync(id); + if (ca == null) return Results.NotFound(); + + var pemBytes = Encoding.UTF8.GetBytes(ca.X509CertificatePem); + return Results.File(pemBytes, "application/x-pem-file", $"{ca.Name}.pem"); + }); + + app.MapGet("/api/issued/{id}/download/pem", async (int id, IDbContextFactory dbFactory) => + { + await using var db = await dbFactory.CreateDbContextAsync(); + var cert = await db.IssuedCertificates.FindAsync(id); + if (cert == null) return Results.NotFound(); + + var pemBytes = Encoding.UTF8.GetBytes(cert.X509CertificatePem); + return Results.File(pemBytes, "application/x-pem-file", $"{cert.Name}.pem"); + }); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} + +static async Task SeedTemplatesAsync(SigilDbContext db) +{ + if (await db.CertificateTemplates.AnyAsync(t => t.IsPreset)) + return; + + db.CertificateTemplates.AddRange( + new CertificateTemplate + { + Name = "Root CA", + Description = "Self-signed Root Certificate Authority with 10-year validity.", + CertificateType = CertificateType.RootCa, + KeyAlgorithm = "RSA", + KeySize = 4096, + ValidityDays = 3650, + KeyUsageFlags = (int)(X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign), + IsKeyUsageCritical = true, + IsBasicConstraintsCa = true, + IsBasicConstraintsCritical = true, + PathLengthConstraint = null, + HashAlgorithm = "SHA256", + IsPreset = true, + }, + new CertificateTemplate + { + Name = "Intermediate CA", + Description = "Intermediate Certificate Authority signed by a Root CA, 5-year validity.", + CertificateType = CertificateType.IntermediateCa, + KeyAlgorithm = "RSA", + KeySize = 4096, + ValidityDays = 1825, + KeyUsageFlags = (int)(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign), + IsKeyUsageCritical = true, + IsBasicConstraintsCa = true, + IsBasicConstraintsCritical = true, + PathLengthConstraint = 0, + HashAlgorithm = "SHA256", + IncludeCdp = true, + IncludeAia = true, + SubjectAltNameTypes = "URI", + IsPreset = true, + }, + new CertificateTemplate + { + Name = "UDAP Client", + Description = "End-entity client certificate for UDAP dynamic client registration.", + CertificateType = CertificateType.EndEntityClient, + KeyAlgorithm = "RSA", + KeySize = 2048, + ValidityDays = 730, + KeyUsageFlags = (int)X509KeyUsageFlags.DigitalSignature, + IsKeyUsageCritical = true, + ExtendedKeyUsageOids = "1.3.6.1.5.5.7.3.2", + IsBasicConstraintsCa = false, + IsBasicConstraintsCritical = true, + HashAlgorithm = "SHA256", + IncludeCdp = true, + IncludeAia = true, + SubjectAltNameTypes = "URI", + IsPreset = true, + }, + new CertificateTemplate + { + Name = "SSL Server", + Description = "End-entity TLS server certificate for HTTPS endpoints.", + CertificateType = CertificateType.EndEntityServer, + KeyAlgorithm = "RSA", + KeySize = 2048, + ValidityDays = 365, + KeyUsageFlags = (int)X509KeyUsageFlags.DigitalSignature, + IsKeyUsageCritical = true, + ExtendedKeyUsageOids = "1.3.6.1.5.5.7.3.1", + IsBasicConstraintsCa = false, + IsBasicConstraintsCritical = true, + HashAlgorithm = "SHA256", + IncludeCdp = true, + SubjectAltNameTypes = "DNS", + IsPreset = true, + } + ); + + await db.SaveChangesAsync(); +} + +/// +/// Allows all requests to the Hangfire dashboard. Used in development only. +/// +class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) => true; +} diff --git a/examples/CA/Sigil/Properties/launchSettings.json b/examples/CA/Sigil/Properties/launchSettings.json new file mode 100644 index 000000000..3a2980c7c --- /dev/null +++ b/examples/CA/Sigil/Properties/launchSettings.json @@ -0,0 +1,39 @@ +{ + "profiles": { + "Sigil": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7200;http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Docker": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ConnectionStrings__SigilDb": "Host=host.docker.internal;Database=sigil;Username=sigil;Password=sigil_pass;Search Path=sigil" + }, + "httpPort": 5200, + "sslPort": 7200, + "useSSL": true + }, + "Docker-GCP": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ConnectionStrings__SigilDb": "Host=host.docker.internal;Database=sigil;Username=sigil;Password=sigil_pass;Search Path=sigil" + }, + "httpPort": 5200, + "sslPort": 7200, + "useSSL": true, + "dockerfileFile": "Dockerfile.gcp", + "dockerfileRunArguments": "-v sigil-gcloud-config:/root/.config/gcloud" + } + } +} diff --git a/examples/CA/Sigil/Services/HangfireRecurringJobScheduler.cs b/examples/CA/Sigil/Services/HangfireRecurringJobScheduler.cs new file mode 100644 index 000000000..cfaefc867 --- /dev/null +++ b/examples/CA/Sigil/Services/HangfireRecurringJobScheduler.cs @@ -0,0 +1,57 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using Hangfire; +using Hangfire.Storage; +using Sigil.Common.Services.Jobs; + +namespace Sigil.Services; + +public class HangfireRecurringJobScheduler : IRecurringJobScheduler +{ + public List GetRecurringJobs() + { + using var connection = JobStorage.Current.GetConnection(); + var recurringJobs = connection.GetRecurringJobs(); + + return recurringJobs.Select(j => new RecurringJobInfo( + j.Id, + j.Cron, + j.LastExecution?.ToString("yyyy-MM-dd HH:mm:ss UTC"), + j.NextExecution?.ToString("yyyy-MM-dd HH:mm:ss UTC") + )).ToList(); + } + + public void UpdateCronExpression(string jobId, string cronExpression) + { + using var connection = JobStorage.Current.GetConnection(); + var jobs = connection.GetRecurringJobs(); + var job = jobs.FirstOrDefault(j => j.Id == jobId); + + if (job == null) + throw new InvalidOperationException($"Recurring job '{jobId}' not found."); + + // Re-register with the same type/method but new cron + RecurringJob.AddOrUpdate( + jobId, + j => j.ExecuteAsync(CancellationToken.None), + cronExpression); + } + + public void TriggerJob(string jobId) + { + RecurringJob.TriggerJob(jobId); + } + + public void RemoveJob(string jobId) + { + RecurringJob.RemoveIfExists(jobId); + } +} diff --git a/examples/CA/Sigil/Sigil.csproj b/examples/CA/Sigil/Sigil.csproj new file mode 100644 index 000000000..c8076ffad --- /dev/null +++ b/examples/CA/Sigil/Sigil.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + sigil-ca-dev + Linux + .. + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + diff --git a/examples/CA/Sigil/appsettings.Development.json b/examples/CA/Sigil/appsettings.Development.json new file mode 100644 index 000000000..b52c491cd --- /dev/null +++ b/examples/CA/Sigil/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Information" + } + } + } +} diff --git a/examples/CA/Sigil/appsettings.json b/examples/CA/Sigil/appsettings.json new file mode 100644 index 000000000..c83016954 --- /dev/null +++ b/examples/CA/Sigil/appsettings.json @@ -0,0 +1,35 @@ +{ + "ConnectionStrings": { + "SigilDb": "Host=localhost;Database=sigil;Username=sigil;Password=sigil_pass;Search Path=sigil" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "AllowedHosts": "*", + "Hangfire": { + "CrlRenewalCron": "0 * * * *" + }, + "Publishing": { + "Providers": [ + { + "Domain": "host.docker.internal:5033", + "Type": "filesystem", + "BaseDirectory": "../Sigil.Certificate.Server/wwwroot" + } + ] + }, + "Signing": { + "Provider": "local" + }, + "GcpKms": { + "ProjectId": "", + "LocationId": "us-central1", + "KeyRingId": "sigil" + } +} diff --git a/examples/CA/Sigil/docs/FEATURES.md b/examples/CA/Sigil/docs/FEATURES.md new file mode 100644 index 000000000..88961b284 --- /dev/null +++ b/examples/CA/Sigil/docs/FEATURES.md @@ -0,0 +1,269 @@ +# Sigil — Implemented Features + +**Stack**: .NET 10, Blazor Server (InteractiveServer), FluentUI v4, PostgreSQL, BouncyCastle, Serilog, Aspire +**Location**: `examples/CA/` in udap-dotnet repo (see Architecture section for full project structure) + +--- + +## Phase 1: Foundation (Complete) + +### Communities +- CRUD page for PKI communities (multi-tenant hierarchy separator) +- Each community is an independent PKI namespace with its own CA tree +- Community selector always visible in Explorer header for quick switching + +### Certificate Explorer +- **Tree view** with hierarchical display: Root CA → Intermediate CA → End-entity certs → CRLs +- **Detail panel** with draggable splitter (FluentSplitter) between tree and details +- Color-coded status badges: Valid, Expiring, Expired, Revoked, Untrusted +- CRL time status badges: Valid, Expiring Soon, Expired +- Collapsible tree items (InitiallyExpanded) + +### Certificate Detail Panel +- General information: Subject, Issuer, Serial, Thumbprint, Validity, Algorithm, Key info, Private key status +- Subject Alternative Names display +- Chain validation with trust anchor detection +- Extension table with resizable columns (JS interop column drag) +- ASN.1 structure viewer (collapsible tree with OID friendly names) +- Operational/Trust Only badges based on private key availability + +### Issuer Navigation +- Clickable link icon on the Issuer field to load issuer certificate details +- Issuer detail panel appears below the primary cert details with blue left border +- Chain walking: click issuer's issuer to navigate up the chain +- Issuer lookup via AKI → SKI matching with DN fallback +- **Tree highlighting** via JS shadow DOM styling: + - Green highlight on selected cert's tree item + - Blue highlight on issuer cert's tree item + - Highlights target the `positioning-region` inside FluentTreeItem's shadow DOM +- Detail panel title bars color-coded to match tree highlights + +### Certificate Import +- Drag & drop file upload (.pfx, .p12, .cer, .crt, .pem, .der, .crl) +- Folder upload with recursive file discovery (up to 500 files) +- **Auto-detection** of certificate role (Root CA, Intermediate CA, End-entity) via BasicConstraints +- PEM fallback for .cer files containing PEM text +- **Batch import** with role-detection pre-sort (Root → Intermediate → End-entity → CRL) and retry pass +- Password dialog queue for PFX files +- Confirm import dialog with parsed cert preview +- **Chain matching** via AKI/SKI, with DN + BouncyCastle signature verification fallback +- **CA selection dialog** for unmatched/untrusted certs (for test harness scenarios) +- Duplicate detection and merge (public-only → PFX upgrade) + +### CRL Handling +- CRL import with signature validation against issuing CA +- CRL number tracking and next update dates +- Revocation entry parsing with reason codes +- CRL detail panel with revoked certificate list + +### Chain Validation +- Full chain validation with revocation checking +- **Online CRL resolution** via CDP (CRL Distribution Points) extension +- Parallel async CRL downloads with 5-second timeout per download +- Pre-computed validation results during tree load (batch validation) +- On-demand revalidation button ("Revalidate" — uses stored CRLs from database) +- **Online-only validation** ("Validate Online" — resolves intermediates via AIA, CRLs via CDP only, no database CRLs) + - Simulates external relying party behavior + - Issuer resolution via AIA `caIssuers` OID (1.3.6.1.5.5.7.48.2) + - "Issuer resolved via AIA" annotation in chain display + - CRL check runs after issuer resolution (CRL signature verified against issuer's public key) +- CRL status indicators: CRL OK, Revoked, No CRL, CRL Expired, CRL Fetch Failed +- Downloaded CRL source URL display + +### Download Endpoints +- `GET /api/ca/{id}/download/cer` — PEM-encoded CA certificate +- `GET /api/ca/{id}/download/pfx` — CA certificate with private key +- `GET /api/ca/{id}/download/pem` — PEM format (.pem extension) +- `GET /api/issued/{id}/download/cer` — PEM-encoded end-entity certificate +- `GET /api/issued/{id}/download/pfx` — End-entity with private key +- `GET /api/issued/{id}/download/pem` — PEM format (.pem extension) +- `GET /api/crl/{id}/download` — DER-encoded CRL + +### Dashboard (Home Page) +- **Quick stats**: Community count, CA count, Issued cert count, Template count +- **Community health cards**: Per-community summary with CA/Issued counts, expired/expiring/overdue CRL counts, healthy indicator +- **Expiring certificates table**: Certs within 60 days of expiry (not yet expired), sorted by NotAfter +- **Expired certificates table**: Most recent 20 expired certs +- **Overdue CRLs table**: CRLs past NextUpdate, sorted by days overdue +- **Revoked certificate count** +- **Deep-linking**: Clickable names navigate to `/explorer/{communityId}?thumbprint={thumbprint}` for auto-selection +- All tables filter out archived items + +### Certificate Management +- **Rename**: Inline rename of certificate display name (pencil icon, Enter to save, Escape to cancel) +- **Archive** (soft-delete): Hides certificate/CRL from tree, preserves in database with `IsArchived` + `ArchivedAt` timestamp +- **Permanent delete**: Full removal from database with cascade cleanup (CRLs, revocations) + - Safety check: CAs with children or issued certs cannot be deleted until dependents are removed/moved +- **Move**: Relocate certificates between communities with AKI/SKI chain re-linking + +### Pre-issuance Validation +- **CDP/AIA URL validation**: HEAD requests to verify endpoints are reachable (5s timeout) +- Warns about missing CDP/AIA when template has them enabled +- Warns about unreachable endpoints with HTTP status or error details +- User can proceed anyway or cancel to fix URLs +- **Template URL token expansion**: `{CAName}` replaced with issuing CA name in CDP/AIA URL templates + +### Issuer Verification (Backend) +- **Signature verification guard** (`CertificateIssuanceService.VerifyIssuedBy`): BouncyCastle DN match + signature verification +- Validates before saving in: issuance, re-sign, manual CA assignment during import, and confirmed import +- Prevents certificates from being linked to a CA that didn't sign them + +### UI +- Dark/light theme toggle with FluentDesignTheme +- Collapsible navigation menu (FluentNavMenu) +- Draggable splitter between tree and detail panels +- Copyable toast notifications (icon-only copy button on error/warning toasts, 15s timeout) +- Communication toasts for success messages (5s timeout) +- **Loading indicator**: Progress ring with "Loading certificates..." shown during tree load +- **Deep-link auto-selection**: Thumbprint query parameter consumed once, then cleared from URL to prevent stale state + +--- + +## Phase 2: Certificate Issuance & Templates (Complete) + +### Certificate Templates +- CRUD page with FluentDataGrid + Add/Edit/Clone/Delete dialog +- **4 preset templates** (seeded on startup, non-deletable): + - Root CA (RSA-4096, 10yr, CertSign+CrlSign) + - Intermediate CA (RSA-4096, 5yr, DigSig+CertSign+CrlSign, CDP+AIA, URI SANs) + - UDAP Client (RSA-2048, 2yr, DigSig, TLS Client Auth EKU, CDP+AIA, URI SANs) + - SSL Server (RSA-2048, 1yr, DigSig, TLS Server Auth EKU, CDP, DNS SANs) +- Clone preset templates to customize +- Configurable fields: + - General: Name, Description, Certificate Type, Validity Days + - Key Parameters: Algorithm (RSA/ECDSA), Key Size (2048/3072/4096), ECDSA Curve (P-256/P-384/P-521), Hash Algorithm (SHA256/384/512) + - Extensions: Key Usage (checkbox flags), Key Usage criticality, BasicConstraints (CA + path length), BasicConstraints criticality + - **EKU picker**: Checkboxes for 10 common OIDs with friendly names (TLS Client/Server Auth, Code Signing, S/MIME, etc.) plus custom OID text field + - Distribution: CDP URL template, AIA URL template + - SAN type hints: URI, DNS, Email, IP (guides issuance UI) + +### Certificate Issuance Engine (`CertificateIssuanceService`) +- Located in `Sigil.Common` for reuse by CLI/API consumers +- Uses .NET `CertificateRequest` API with BouncyCastle extension helpers +- **RSA + ECDSA** key generation +- Self-signed root CA creation +- CA-signed intermediate and end-entity certificate creation +- Extension building from template: BasicConstraints, KeyUsage, SKI, AKI, EKU, CDP, AIA, SANs +- **Extension helpers** (`CertificateExtensionHelpers`): AddAuthorityKeyIdentifier, MakeCdp, BuildAiaExtension — extracted from Udap.PKI.Generator for reuse +- Serial numbers: `RandomNumberGenerator.GetBytes(16)` +- **Validity clamping**: NotAfter automatically clamped to issuing CA's NotAfter +- PFX + PEM export and database storage + +### Issue Certificate Flow (Explorer UI) +- **"New Root CA"** button in Explorer header (self-signed, Root CA templates only) +- **"Issue Certificate"** button on CA detail panel (visible when CA has private key) +- Issuance dialog with: + - Template selector (filtered by cert type) + - Subject DN field + - Display name field + - Validity date pickers (auto-calculated from template, clamped to issuer) + - CDP/AIA URL fields (conditional on template flags) + - Dynamic SAN entries (add/remove, type selector: URI/DNS/Email/IP) + - PFX password + - Template summary showing key params +- Post-generation: toast with thumbprint, tree refresh + +### Certificate Renewal + +#### Re-key (New Key Pair) +- Available on all certificate types +- Creates new certificate with new key pair, new serial, new validity +- Pre-fills subject, name, and SANs from existing cert +- SAN parsing handles all formats: `URL=`, `DNS Name=`, `RFC822 Name=`, `IP Address=`, and DB storage format + +#### Re-sign (Same Key, New Validity) +- Available on Root CA and Intermediate CA certs with private keys +- **Updates the existing entity in-place** — same DB row, all child relationships preserved +- Loads existing private key from PFX +- Builds new CertificateRequest with same public key +- Copies all extensions from original cert (preserves SKI, AKI, BasicConstraints, etc.) +- Signs with parent CA (or self-signs for roots) +- New serial number + new validity period +- Downstream certs continue to validate (AKI→SKI match unchanged) +- Validity clamped to parent CA's NotAfter + +--- + +--- + +## Phase 5: Remote Signing & Aspire Orchestration (Complete) + +### Signing Provider Architecture +- **`ISigningProvider` interface** in `Sigil.Common` — pluggable signing abstraction + - `GenerateKeyAsync` — creates key pair in the provider + - `GetPublicKeyAsync` — retrieves public key for certificate building + - `SignDataAsync` — signs TBS (to-be-signed) data +- **`SigningKeyReference`** record — identifies a key across provider boundaries (provider, keyId, algorithm, size) +- **`SigningProviderOptions`** — configuration-driven provider selection: `"local"`, `"vault-transit"`, or `"gcp-kms"` +- **`RemoteCertificateBuilder`** — BouncyCastle-based certificate assembly with async remote signing (avoids sync-over-async deadlock in Blazor Server) + +### Signing Providers (Separate Projects) +- **`LocalSigningProvider`** (in `Sigil.Common`) — in-memory RSA/ECDSA keys, default provider +- **`Sigil.Vault.Transit`** — HashiCorp Vault Transit secrets engine + - Private keys never leave Vault; signs via REST API + - P1363→DER ECDSA signature format conversion + - Key lifecycle: create, get public key, sign, delete +- **`Sigil.Gcp.Kms`** — Google Cloud KMS + - Private keys never leave Cloud HSM/KMS + - Uses Application Default Credentials (gcloud CLI or service account) + - Pre-computed digest signing via `AsymmetricSign` API + - Key ring auto-creation, key version lifecycle management + +### Three Signing Paths +1. **Full Remote** — key generation AND signing in remote provider (Vault/GCP KMS); no PFX export; `StoreProviderHint = "{provider}:{keyId}"` +2. **Hybrid** — local key generation (PFX exportable) + remote CA signing (e.g., end-entity cert with local key, signed by Vault-backed CA) +3. **Full Local** — both key and signing local (existing behavior) + +### Docker & GCP Integration +- **Dockerfile** — multi-stage build for VS Container Tools compatibility +- **Dockerfile.gcp** — same + gcloud CLI installed, persistent credential volume +- **VS launch profiles**: Sigil (desktop), Docker, Docker-GCP +- **Aspire `WithHttpsCertificateConfiguration`** — injects trusted dev cert into containers +- **GCP credential isolation** — Docker named volume (`sigil-gcloud-config`), never committed to source + +### Aspire Orchestration (`Sigil.AppHost`) +- **Vault container** with Transit engine auto-configured (key creation via lifecycle hook) +- **Switchable hosting modes** via launch profiles: `project`, `docker`, `docker-gcp` +- **Switchable signing providers** via `Sigil:SigningProvider`: `vault-transit` or `gcp-kms` +- **Launch profiles**: `https`, `https-docker`, `https-docker-gcp`, `https-docker-gcp-kms`, `http` + +### Unit Tests (`_tests/Sigil.Signing.Tests`) +- **LocalSigningProvider**: key generation (RSA/ECDSA), signature verification round-trip, multi-hash support, error handling +- **VaultTransitSigningProvider**: P1363→DER conversion correctness (P-256, P-384), high-bit padding, leading-zero trimming, fuzz-style round-trip +- **SigningKeyReference**: record equality, deconstruction + +--- + +## Architecture + +### Project Structure +``` +examples/CA/ +├── Sigil/ # Blazor Server host (Program.cs, App.razor, wwwroot) +├── Sigil.Common/ # Class library (entities, services, ViewModels, migrations) +│ ├── Data/Entities/ # Community, CaCertificate, IssuedCertificate, Crl, CertificateTemplate, Job +│ ├── Services/ # Issuance, Validation, Parsing, Import, CRL, ASN.1, Extension helpers +│ ├── Services/Signing/ # ISigningProvider, LocalSigningProvider, RemoteCertificateBuilder +│ └── ViewModels/ # DTOs for UI and API consumers +├── Sigil.UI/ # Razor Class Library (all components, pages, shared, layout) +│ ├── Components/Pages/ # Explorer, Communities, Templates, Import, Home, Jobs +│ ├── Components/Shared/ # Asn1TreeView, CertBadge, CrlBadge, ExtensionTable, CopyableToast +│ └── Services/ # Toast extensions +├── Sigil.Vault.Transit/ # ISigningProvider implementation for HashiCorp Vault Transit +├── Sigil.Gcp.Kms/ # ISigningProvider implementation for Google Cloud KMS +├── Sigil.Vault.Hosting/ # Aspire hosting integration for Vault dev container +├── Sigil.ServiceDefaults/ # Aspire service defaults (OpenTelemetry, health checks) +├── Sigil.AppHost/ # Aspire orchestrator (Vault + Sigil) +└── _tests/ + └── Sigil.Signing.Tests/ # Unit tests for signing providers +``` + +### Key Design Decisions +- **Sigil.Common has no UI dependencies** — reusable by CLI tools, APIs, tests +- **Provider projects are separate assemblies** — `Sigil.Vault.Transit` and `Sigil.Gcp.Kms` implement `ISigningProvider` from `Sigil.Common`, keeping provider-specific dependencies (Vault HTTP, Google.Cloud.Kms) out of the core library +- **CertificateIssuanceService** and all DTOs in Common for multi-host consumption +- **Extension helpers** extracted from Udap.PKI.Generator test project into Sigil.Common +- **Tree highlighting** via JS interop into FluentTreeItem shadow DOM (avoids Blazor re-render/collapse) +- **Re-sign updates in-place** to preserve child relationships (vs creating new entity) +- **Preset templates** seeded idempotently on startup +- **`StoreProviderHint`** metadata tracks which provider holds each key (e.g., `"vault-transit:sigil-abc123"`, `"gcp-kms:sigil-def456"`) diff --git a/examples/CA/Sigil/wwwroot/css/site.css b/examples/CA/Sigil/wwwroot/css/site.css new file mode 100644 index 000000000..a631a3e3e --- /dev/null +++ b/examples/CA/Sigil/wwwroot/css/site.css @@ -0,0 +1,27 @@ +/* Minimal overrides - Fluent UI handles theming */ +code { + font-size: 0.8rem; + opacity: 0.7; +} + +/* Data grid row spacing and height */ +.fluent-data-grid-row td { + height: auto !important; + min-height: 48px !important; + padding: 8px 0 !important; +} + +.fluent-data-grid-row th { + min-height: 44px !important; +} + +/* Soften the column resize handle */ +.fluent-data-grid .col-width-draghandle { + width: 2px; + opacity: 0.15; + background: currentColor; +} + +.fluent-data-grid .col-width-draghandle:hover { + opacity: 0.5; +} diff --git a/examples/CA/_tests/Sigil.Signing.Tests/LocalSigningProviderTests.cs b/examples/CA/_tests/Sigil.Signing.Tests/LocalSigningProviderTests.cs new file mode 100644 index 000000000..96a61d0c1 --- /dev/null +++ b/examples/CA/_tests/Sigil.Signing.Tests/LocalSigningProviderTests.cs @@ -0,0 +1,163 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using FluentAssertions; +using Sigil.Common.Services.Signing; + +namespace Sigil.Signing.Tests; + +public class LocalSigningProviderTests : IDisposable +{ + private readonly LocalSigningProvider _provider = new(); + + [Fact] + public void ProviderName_ReturnsLocal() + { + _provider.ProviderName.Should().Be("local"); + } + + [Theory] + [InlineData("RSA", 2048, null)] + [InlineData("RSA", 4096, null)] + [InlineData("ECDSA", 0, "nistp256")] + [InlineData("ECDSA", 0, "nistp384")] + public async Task GenerateKeyAsync_CreatesKeyAndReturnsReference( + string algorithm, int keySize, string? curve) + { + var keyRef = await _provider.GenerateKeyAsync(algorithm, keySize, curve); + + keyRef.Provider.Should().Be("local"); + keyRef.KeyAlgorithm.Should().Be(algorithm); + keyRef.KeyIdentifier.Should().NotBeNullOrEmpty(); + keyRef.KeySize.Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetPublicKeyAsync_ReturnsRsaKey() + { + var keyRef = await _provider.GenerateKeyAsync("RSA", 2048); + + using var publicKey = await _provider.GetPublicKeyAsync(keyRef); + + publicKey.Should().BeAssignableTo(); + } + + [Fact] + public async Task GetPublicKeyAsync_ReturnsEcdsaKey() + { + var keyRef = await _provider.GenerateKeyAsync("ECDSA", 0, "nistp384"); + + using var publicKey = await _provider.GetPublicKeyAsync(keyRef); + + publicKey.Should().BeAssignableTo(); + } + + [Fact] + public async Task GetPublicKeyAsync_ThrowsForUnknownKey() + { + var bogusRef = new SigningKeyReference("local", "nonexistent", "RSA", 2048); + + var act = () => _provider.GetPublicKeyAsync(bogusRef); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task SignDataAsync_Rsa_ProducesVerifiableSignature() + { + var keyRef = await _provider.GenerateKeyAsync("RSA", 2048); + var data = "test data to sign"u8.ToArray(); + + var signature = await _provider.SignDataAsync(data, HashAlgorithmName.SHA256, keyRef); + + signature.Should().NotBeNullOrEmpty(); + + // Verify the signature using the public key + using var rsa = (RSA)await _provider.GetPublicKeyAsync(keyRef); + var isValid = rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + isValid.Should().BeTrue(); + } + + [Fact] + public async Task SignDataAsync_Ecdsa_ProducesVerifiableSignature() + { + var keyRef = await _provider.GenerateKeyAsync("ECDSA", 0, "nistp384"); + var data = "test data to sign"u8.ToArray(); + + var signature = await _provider.SignDataAsync(data, HashAlgorithmName.SHA384, keyRef); + + signature.Should().NotBeNullOrEmpty(); + + // Verify the signature using the public key + using var ecdsa = (ECDsa)await _provider.GetPublicKeyAsync(keyRef); + var isValid = ecdsa.VerifyData(data, signature, HashAlgorithmName.SHA384); + isValid.Should().BeTrue(); + } + + [Fact] + public async Task SignDataAsync_ThrowsForUnknownKey() + { + var bogusRef = new SigningKeyReference("local", "nonexistent", "RSA", 2048); + var data = "test"u8.ToArray(); + + var act = () => _provider.SignDataAsync(data, HashAlgorithmName.SHA256, bogusRef); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task SignDataAsync_DifferentData_ProducesDifferentSignatures() + { + var keyRef = await _provider.GenerateKeyAsync("RSA", 2048); + var data1 = "first message"u8.ToArray(); + var data2 = "second message"u8.ToArray(); + + var sig1 = await _provider.SignDataAsync(data1, HashAlgorithmName.SHA256, keyRef); + var sig2 = await _provider.SignDataAsync(data2, HashAlgorithmName.SHA256, keyRef); + + sig1.Should().NotBeEquivalentTo(sig2); + } + + [Fact] + public async Task GenerateKeyAsync_EachCallCreatesUniqueKey() + { + var keyRef1 = await _provider.GenerateKeyAsync("RSA", 2048); + var keyRef2 = await _provider.GenerateKeyAsync("RSA", 2048); + + keyRef1.KeyIdentifier.Should().NotBe(keyRef2.KeyIdentifier); + } + + [Theory] + [InlineData("SHA256")] + [InlineData("SHA384")] + [InlineData("SHA512")] + public async Task SignDataAsync_Rsa_SupportsMultipleHashAlgorithms(string hashName) + { + var hashAlgorithm = new HashAlgorithmName(hashName); + var keyRef = await _provider.GenerateKeyAsync("RSA", 2048); + var data = "hash algorithm test"u8.ToArray(); + + var signature = await _provider.SignDataAsync(data, hashAlgorithm, keyRef); + + signature.Should().NotBeNullOrEmpty(); + + using var rsa = (RSA)await _provider.GetPublicKeyAsync(keyRef); + rsa.VerifyData(data, signature, hashAlgorithm, RSASignaturePadding.Pkcs1) + .Should().BeTrue(); + } + + public void Dispose() + { + _provider.Dispose(); + } +} diff --git a/examples/CA/_tests/Sigil.Signing.Tests/Sigil.Signing.Tests.csproj b/examples/CA/_tests/Sigil.Signing.Tests/Sigil.Signing.Tests.csproj new file mode 100644 index 000000000..b5046f673 --- /dev/null +++ b/examples/CA/_tests/Sigil.Signing.Tests/Sigil.Signing.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/examples/CA/_tests/Sigil.Signing.Tests/SigningKeyReferenceTests.cs b/examples/CA/_tests/Sigil.Signing.Tests/SigningKeyReferenceTests.cs new file mode 100644 index 000000000..2b26303f1 --- /dev/null +++ b/examples/CA/_tests/Sigil.Signing.Tests/SigningKeyReferenceTests.cs @@ -0,0 +1,57 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using FluentAssertions; +using Sigil.Common.Services.Signing; + +namespace Sigil.Signing.Tests; + +public class SigningKeyReferenceTests +{ + [Fact] + public void Record_Equality_ByValue() + { + var ref1 = new SigningKeyReference("vault-transit", "key-1", "RSA", 4096); + var ref2 = new SigningKeyReference("vault-transit", "key-1", "RSA", 4096); + + ref1.Should().Be(ref2); + } + + [Fact] + public void Record_Inequality_DifferentProvider() + { + var ref1 = new SigningKeyReference("vault-transit", "key-1", "RSA", 4096); + var ref2 = new SigningKeyReference("gcp-kms", "key-1", "RSA", 4096); + + ref1.Should().NotBe(ref2); + } + + [Fact] + public void Record_Inequality_DifferentKeyIdentifier() + { + var ref1 = new SigningKeyReference("local", "aaa", "RSA", 2048); + var ref2 = new SigningKeyReference("local", "bbb", "RSA", 2048); + + ref1.Should().NotBe(ref2); + } + + [Fact] + public void Deconstruction_Works() + { + var keyRef = new SigningKeyReference("gcp-kms", "sigil-abc123", "ECDSA", 384); + + var (provider, keyId, algorithm, size) = keyRef; + + provider.Should().Be("gcp-kms"); + keyId.Should().Be("sigil-abc123"); + algorithm.Should().Be("ECDSA"); + size.Should().Be(384); + } +} diff --git a/examples/CA/_tests/Sigil.Signing.Tests/Usings.cs b/examples/CA/_tests/Sigil.Signing.Tests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/examples/CA/_tests/Sigil.Signing.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/examples/CA/_tests/Sigil.Signing.Tests/VaultTransitP1363ConversionTests.cs b/examples/CA/_tests/Sigil.Signing.Tests/VaultTransitP1363ConversionTests.cs new file mode 100644 index 000000000..ba3d60a68 --- /dev/null +++ b/examples/CA/_tests/Sigil.Signing.Tests/VaultTransitP1363ConversionTests.cs @@ -0,0 +1,138 @@ +#region (c) 2026 Joseph Shook. All rights reserved. +// /* +// Authors: +// Joseph Shook JoeShook@Gmail.com +// Joseph.Shook@Surescripts.com +// +// See LICENSE in the project root for license information. +// */ +#endregion + +using System.Security.Cryptography; +using FluentAssertions; +using Sigil.Vault; + +namespace Sigil.Signing.Tests; + +/// +/// Tests for the P1363-to-DER conversion used by VaultTransitSigningProvider. +/// Vault returns ECDSA signatures in IEEE P1363 format (r || s), but BouncyCastle +/// and .NET X.509 expect DER-encoded signatures. This conversion is critical for +/// certificate validity. +/// +public class VaultTransitP1363ConversionTests +{ + [Fact] + public void ConvertP1363ToDer_P256Signature_ProducesValidDer() + { + // Generate a real P1363 signature using .NET + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var data = "test data"u8.ToArray(); + var p1363 = ecdsa.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + // Convert to DER + var der = VaultTransitSigningProvider.ConvertP1363ToDer(p1363); + + // Verify the DER signature + var isValid = ecdsa.VerifyData(data, der, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + isValid.Should().BeTrue("DER-converted signature should verify against original key"); + } + + [Fact] + public void ConvertP1363ToDer_P384Signature_ProducesValidDer() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP384); + var data = "test data for P384"u8.ToArray(); + var p1363 = ecdsa.SignData(data, HashAlgorithmName.SHA384, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + var der = VaultTransitSigningProvider.ConvertP1363ToDer(p1363); + + var isValid = ecdsa.VerifyData(data, der, HashAlgorithmName.SHA384, DSASignatureFormat.Rfc3279DerSequence); + isValid.Should().BeTrue(); + } + + [Fact] + public void ConvertP1363ToDer_OutputStartsWithSequenceTag() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var data = "test"u8.ToArray(); + var p1363 = ecdsa.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + var der = VaultTransitSigningProvider.ConvertP1363ToDer(p1363); + + der[0].Should().Be(0x30, "DER SEQUENCE tag"); + der[2].Should().Be(0x02, "first DER INTEGER tag (r)"); + } + + [Fact] + public void ConvertP1363ToDer_HandlesHighBitPadding() + { + // Create a known P1363 signature where r has high bit set (needs 0x00 pad) + var halfLen = 32; // P-256 + var p1363 = new byte[halfLen * 2]; + + // Set r with high bit = 1 (needs padding in DER) + p1363[0] = 0x80; + for (int i = 1; i < halfLen; i++) p1363[i] = 0x42; + + // Set s normally + p1363[halfLen] = 0x01; + for (int i = halfLen + 1; i < p1363.Length; i++) p1363[i] = 0x23; + + var der = VaultTransitSigningProvider.ConvertP1363ToDer(p1363); + + // r should have a leading 0x00 pad + der[0].Should().Be(0x30); // SEQUENCE + der[2].Should().Be(0x02); // INTEGER + der[3].Should().Be((byte)(halfLen + 1)); // length includes pad + der[4].Should().Be(0x00); // padding byte + der[5].Should().Be(0x80); // original first byte + } + + [Fact] + public void ConvertP1363ToDer_TrimsLeadingZeros() + { + var halfLen = 32; + var p1363 = new byte[halfLen * 2]; + + // r starts with several leading zeros + p1363[0] = 0x00; + p1363[1] = 0x00; + p1363[2] = 0x01; + for (int i = 3; i < halfLen; i++) p1363[i] = 0x42; + + // s is normal + p1363[halfLen] = 0x42; + for (int i = halfLen + 1; i < p1363.Length; i++) p1363[i] = 0x23; + + var der = VaultTransitSigningProvider.ConvertP1363ToDer(p1363); + + // r INTEGER should have trimmed leading zeros + der[2].Should().Be(0x02); // INTEGER tag + var rLen = der[3]; + rLen.Should().Be((byte)(halfLen - 2)); // trimmed 2 leading zeros + } + + [Fact] + public void ConvertP1363ToDer_RoundTrip_MultipleIterations() + { + // Fuzz-style test: generate many signatures and verify round-trip + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP384); + var data = new byte[100]; + Random.Shared.NextBytes(data); + + for (int i = 0; i < 50; i++) + { + var p1363 = ecdsa.SignData(data, HashAlgorithmName.SHA384, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + var der = VaultTransitSigningProvider.ConvertP1363ToDer(p1363); + + var isValid = ecdsa.VerifyData(data, der, HashAlgorithmName.SHA384, + DSASignatureFormat.Rfc3279DerSequence); + isValid.Should().BeTrue($"round-trip iteration {i} should produce valid DER"); + + // Vary the data for each iteration + data[i % data.Length] ^= 0xFF; + } + } +} diff --git a/examples/FhirLabsApi/FhirLabsApi.csproj b/examples/FhirLabsApi/FhirLabsApi.csproj index c2e08462a..56edc86b1 100644 --- a/examples/FhirLabsApi/FhirLabsApi.csproj +++ b/examples/FhirLabsApi/FhirLabsApi.csproj @@ -108,6 +108,9 @@ PreserveNewest + + PreserveNewest + Always @@ -154,6 +157,4 @@ Always - - \ No newline at end of file diff --git a/examples/FhirLabsApi/Properties/launchSettings.json b/examples/FhirLabsApi/Properties/launchSettings.json index c60f7de42..90c994a16 100644 --- a/examples/FhirLabsApi/Properties/launchSettings.json +++ b/examples/FhirLabsApi/Properties/launchSettings.json @@ -1,6 +1,17 @@ { "profiles": { - "FhirLabsApi_Local": { + "FhirLabsApi_Localhost": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "fhir/r4/.well-known/udap/communities/ashtml", + "applicationUrl": "https://localhost:7016;http://localhost:5016", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "targetFramework": "net10.0" + }, + "FhirLabsApi_Local_docker": { "commandName": "Project", "launchBrowser": true, "launchUrl": "fhir/r4/.well-known/udap/communities/ashtml", @@ -9,7 +20,7 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "targetFramework": "net8.0" + "targetFramework": "net10.0" }, "FhirLabsApi": { "commandName": "Project", diff --git a/examples/FhirLabsApi/appsettings.Development.json b/examples/FhirLabsApi/appsettings.Development.json index 1ab13bafc..ccc43a4f7 100644 --- a/examples/FhirLabsApi/appsettings.Development.json +++ b/examples/FhirLabsApi/appsettings.Development.json @@ -62,6 +62,15 @@ } ] }, + { + "Name": "urn:oid:2.16.840.1.113883.3.7204.1.5", + "IssuedCerts": [ + { + "FilePath": "CertStore/issued/fhirlabs.net.tefca.server.pfx", + "Password": "udap-test" + } + ] + }, { "Name": "udap://multihost/", "IssuedCerts": [ diff --git a/examples/FhirLabsApi/docs/README.md b/examples/FhirLabsApi/docs/README.md index 664808bf5..7c912eeab 100644 --- a/examples/FhirLabsApi/docs/README.md +++ b/examples/FhirLabsApi/docs/README.md @@ -1,7 +1,9 @@ -# Udap.Client +# FhirLabsApi ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package +## FHIR R4B Resource Server Example -This the spirit of this package is an easy to use client. I have not put time in to ensure this is easy to use yet. Work in progress... \ No newline at end of file +A reference FHIR server implementation that passes all [udap.org](https://udap.org) conformance tests. Uses a directory-based FHIR data store with UDAP metadata and SMART App Launch metadata endpoints for security and OAuth configuration. + +This server is deployed at [fhirlabs.net](https://fhirlabs.net/fhir/r4/.well-known/udap). diff --git a/examples/FhirLabsApi/udap.metadata.options.Development.json b/examples/FhirLabsApi/udap.metadata.options.Development.json index cb5b90795..7349f1e21 100644 --- a/examples/FhirLabsApi/udap.metadata.options.Development.json +++ b/examples/FhirLabsApi/udap.metadata.options.Development.json @@ -87,6 +87,19 @@ } }, + { + "Community": "urn:oid:2.16.840.1.113883.3.7204.1.5", + "UdapAuthorizationExtensionsSupported": [ "hl7-b2b", "tefca-ias" ], + "UdapAuthorizationExtensionsRequired": [ "hl7-b2b" ], + "UdapCertificationsSupported": [ "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification" ], + "UdapCertificationsRequired": [ "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification" ], + "SignedMetadataConfig": { + "AuthorizationEndpoint": "https://host.docker.internal:5002/connect/authorize", + "TokenEndpoint": "https://host.docker.internal:5002/connect/token", + "RegistrationEndpoint": "https://host.docker.internal:5002/connect/register" + } + }, + { "Community": "udap://multihost/", "SignedMetadataConfig": { diff --git a/examples/README.md b/examples/README.md index c038b50d5..6f2738375 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,9 +27,8 @@ An alternative ```tye.yaml``` file called ```tye.docker.yaml``` has been created - Udap.Identity.Provider - Udap.Identity.Provider.2 -The following two are running locally. +The following is running locally. -- UdapEd.Server - Udap.Certificate.Server Run Tye using the Docker technique with the following command. @@ -75,4 +74,4 @@ Also run the MakeCaWithIntermediateUdapForLocalhostCommunity unit test to genera ## Tiered OAuth -There is a Tiered OAuth path that will work locally with this current setup. In the UdapEd tool type ```https://host.docker.internal:5057``` in the ```OpenID Connect IdP``` field. Then use username/password of ```bob/bob``` or ```alice/alice```. +There is a Tiered OAuth path that will work locally with this current setup. In the [UdapEd](https://github.com/JoeShook/UdapEd) tool type ```https://host.docker.internal:5057``` in the ```OpenID Connect IdP``` field. Then use username/password of ```bob/bob``` or ```alice/alice```. diff --git a/examples/Tefca.Proxy.Server/README.md b/examples/Tefca.Proxy.Server/README.md index 63b5a0725..1065a1117 100644 --- a/examples/Tefca.Proxy.Server/README.md +++ b/examples/Tefca.Proxy.Server/README.md @@ -1,28 +1,29 @@ -# TEFCA UDAP Proxy Server +# TEFCA UDAP Proxy Server -Build on dotnet [YARP](https://microsoft.github.io/reverse-proxy/) (Yet Another Reverse Proxy) and ASP.NET Core 8.0 +Built on dotnet [YARP](https://microsoft.github.io/reverse-proxy/) (Yet Another Reverse Proxy) and ASP.NET Core. +A TEFCA-configured variant of the [UDAP Proxy Server](../Udap.Proxy.Server/), designed for TEFCA Facilitated FHIR exchange. Like the base proxy, it sits in front of an existing FHIR server and provides UDAP metadata and SMART on FHIR metadata endpoints via the [Udap.Metadata.Server](https://www.nuget.org/packages/Udap.Metadata.Server) and [Udap.Smart.Metadata](https://www.nuget.org/packages/Udap.Smart.Metadata) packages. -The proxy server is the source of a UDAP metadata endpoint and a SMART on FHIR metadata endpoint. -The [Udap.Metadata.Server](https://www.nuget.org/packages/Udap.Metadata.Server) and [Udap.Smart.Metadata](https://www.nuget.org/packages/Udap.Smart.Metadata) -packages are used to generate the metadata for the UDAP and SMART on FHIR endpoints, along with appsettings. +## TEFCA-Specific Features -YARP Routes and clusters are all configured in appsettings in this example. +- **Cloud secret mounting** for TEFCA-specific configuration (`/secret/tefcaproxyserverappsettings`, `/secret/udap.tefca.metadata.options.json`) +- **GCP Application Default Credentials (ADC)** for backend FHIR server authentication +- **Response URL transformation** — rewrites resource URLs from backend to proxy-facing URLs for paging and references +- **JWT claim forwarding** — extracts scopes and issuer from access tokens and passes them as custom headers to the backend FHIR server +- **FHIR metadata caching and transformation** -## Important concepts. +## Important Concepts -The proxy has anonymous routes and UDAP secured routes. To access the FHIR resources, the client must follow UDAP Dynamic Client Registration and obtain an access token. +The proxy has anonymous routes and UDAP secured routes. To access FHIR resources, the client must follow UDAP Dynamic Client Registration and obtain an access token. -All other routes are anonymous. :spiral_notepad: Note: You will see a SMART endpoints. This is in anticipation of a future Udap.Auth.Server that also can facilitate SMART on FHIR clients. +### Bearer Token -Two interesting functiolities are implemented in this proxy server you may want to look at right away. +In this proxy scenario your FHIR server is secured by a standard bearer access token. The code implements a GCP ADC technique based on a GCP service account. There is also a simple AccessToken technique — set `ReverseProxy:Routes:Metadata:AccessToken` to the name of an environment variable supplying your access token. Or write your own. - #### 1. **Bearer Token** - In this Proxy scenario your FHIR server will be secured by some mechanism. Most likely a standard Bearer Access token. The code implements a GCP ADC technique base on a GCP Service account. There is also a simple AccessToken technique. In the appsettings you can set ReverseProxy:Routes:Metadata:AccessToken value to the name of an environment variable that supplies your access token. Or write your own. - #### 2. **Transforms** - The URLs in the FHIR resources must be transformed. Specifically for things like paging. I have not put much time into this so I expect this will not be good enough. - +### Transforms -![Logical Diagram](./docs/udap-proxy-logical.svg) - -You will of course need to configure for your FHIR server. This example does not include an accessible FHIR Server. Although it is the FHIR server used by fhirlabs.net and includes all the bits to deploy to a GCP Cloud Run application. +URLs in FHIR resources (e.g., paging links) are transformed from backend URLs to proxy-facing URLs. +![Logical Diagram](./docs/udap-proxy-logical.svg) +You will need to configure for your FHIR server. This example is configured for the FHIR server used by fhirlabs.net and includes the bits to deploy to a GCP Cloud Run application. diff --git a/examples/Tefca.Proxy.Server/Tefca.Proxy.Server.csproj b/examples/Tefca.Proxy.Server/Tefca.Proxy.Server.csproj index 1fecbe779..6bd68f0ac 100644 --- a/examples/Tefca.Proxy.Server/Tefca.Proxy.Server.csproj +++ b/examples/Tefca.Proxy.Server/Tefca.Proxy.Server.csproj @@ -26,10 +26,10 @@ - - - - + + + + diff --git a/examples/Udap.Auth.Server.Admin/Udap.Auth.Server.Admin.csproj b/examples/Udap.Auth.Server.Admin/Udap.Auth.Server.Admin.csproj index 2e4893656..c6a6b91b6 100644 --- a/examples/Udap.Auth.Server.Admin/Udap.Auth.Server.Admin.csproj +++ b/examples/Udap.Auth.Server.Admin/Udap.Auth.Server.Admin.csproj @@ -35,7 +35,7 @@ - + diff --git a/examples/Udap.Auth.Server/HostingExtensions.cs b/examples/Udap.Auth.Server/HostingExtensions.cs index 34c985b02..abda21d18 100644 --- a/examples/Udap.Auth.Server/HostingExtensions.cs +++ b/examples/Udap.Auth.Server/HostingExtensions.cs @@ -25,6 +25,8 @@ using Udap.Server.Configuration; using Udap.Server.Security.Authentication.TieredOAuth; using Udap.Server.Storage.DbContexts; +using Udap.Tefca.Model; +using Udap.Tefca.Server; namespace Udap.Auth.Server; @@ -107,7 +109,16 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde .AddUdapResponseGenerators() .AddSmartV2Expander(); + builder.Services.AddUdapSsraaValidation(options => + { + options.Communities.Add("udap://fhirlabs.net"); + }); + builder.Services.AddUdapTefcaExtensions(); + builder.Services.AddUdapTefcaValidation(options => + { + options.Communities.Add("tefca://test-community"); + }); builder.Services.Configure(builder.Configuration.GetSection(Constants.UdapFileCertStoreManifestSectionName)); diff --git a/examples/Udap.Auth.Server/Udap.Auth.Server.csproj b/examples/Udap.Auth.Server/Udap.Auth.Server.csproj index 9f4de9eaa..33f717b63 100644 --- a/examples/Udap.Auth.Server/Udap.Auth.Server.csproj +++ b/examples/Udap.Auth.Server/Udap.Auth.Server.csproj @@ -27,9 +27,9 @@ - + - + @@ -39,12 +39,18 @@ - - + + + + + + + + diff --git a/examples/Udap.Auth.Server/appsettings.Development.json b/examples/Udap.Auth.Server/appsettings.Development.json index c77a5cc20..2c2fb6f85 100644 --- a/examples/Udap.Auth.Server/appsettings.Development.json +++ b/examples/Udap.Auth.Server/appsettings.Development.json @@ -29,101 +29,7 @@ "SsraaVersion": "V2_0", //https://hl7.org/fhir/smart-app-launch/scopes-and-launch-context.html "DefaultSystemScopes": "openid system/*.rs system/*.read", - "DefaultUserScopes": "openid user/*.rs user/*/read", - "ClientCredentialsExtensionsRequired": [ "hl7-b2b" ], - - // HL7 v3 PurposeOfUse value set (OID: 2.16.840.1.113883.5.8) - // https://terminology.hl7.org/ValueSet-v3-PurposeOfUse.html - "AllowedPurposeOfUse": [ - "urn:oid:2.16.840.1.113883.5.8#TREAT", - "urn:oid:2.16.840.1.113883.5.8#ETREAT", - "urn:oid:2.16.840.1.113883.5.8#BTG", - "urn:oid:2.16.840.1.113883.5.8#ERTREAT", - "urn:oid:2.16.840.1.113883.5.8#COC", - "urn:oid:2.16.840.1.113883.5.8#CLINTRL", - "urn:oid:2.16.840.1.113883.5.8#POPHLTH", - "urn:oid:2.16.840.1.113883.5.8#TREATDS", - "urn:oid:2.16.840.1.113883.5.8#HPAYMT", - "urn:oid:2.16.840.1.113883.5.8#CLMATTCH", - "urn:oid:2.16.840.1.113883.5.8#COVAUTH", - "urn:oid:2.16.840.1.113883.5.8#COVERAGE", - "urn:oid:2.16.840.1.113883.5.8#ELIGDTRM", - "urn:oid:2.16.840.1.113883.5.8#ELIGVER", - "urn:oid:2.16.840.1.113883.5.8#ENROLLM", - "urn:oid:2.16.840.1.113883.5.8#MILDCRG", - "urn:oid:2.16.840.1.113883.5.8#REMITADV", - "urn:oid:2.16.840.1.113883.5.8#PMTDS", - "urn:oid:2.16.840.1.113883.5.8#HOPERAT", - "urn:oid:2.16.840.1.113883.5.8#CAREMGT", - "urn:oid:2.16.840.1.113883.5.8#DONAT", - "urn:oid:2.16.840.1.113883.5.8#FRAUD", - "urn:oid:2.16.840.1.113883.5.8#GOV", - "urn:oid:2.16.840.1.113883.5.8#HACCRED", - "urn:oid:2.16.840.1.113883.5.8#HCOMPL", - "urn:oid:2.16.840.1.113883.5.8#HDECD", - "urn:oid:2.16.840.1.113883.5.8#HDIRECT", - "urn:oid:2.16.840.1.113883.5.8#HDM", - "urn:oid:2.16.840.1.113883.5.8#HLEGAL", - "urn:oid:2.16.840.1.113883.5.8#HOUTCOMS", - "urn:oid:2.16.840.1.113883.5.8#HPRGRP", - "urn:oid:2.16.840.1.113883.5.8#HQUALIMP", - "urn:oid:2.16.840.1.113883.5.8#HSYSADMIN", - "urn:oid:2.16.840.1.113883.5.8#LABELING", - "urn:oid:2.16.840.1.113883.5.8#METAMGT", - "urn:oid:2.16.840.1.113883.5.8#MEMADMIN", - "urn:oid:2.16.840.1.113883.5.8#MILCDM", - "urn:oid:2.16.840.1.113883.5.8#PATADMIN", - "urn:oid:2.16.840.1.113883.5.8#PATSFTY", - "urn:oid:2.16.840.1.113883.5.8#PERFMSR", - "urn:oid:2.16.840.1.113883.5.8#RECORDMGT", - "urn:oid:2.16.840.1.113883.5.8#SYSDEV", - "urn:oid:2.16.840.1.113883.5.8#HTEST", - "urn:oid:2.16.840.1.113883.5.8#TRAIN", - "urn:oid:2.16.840.1.113883.5.8#MLTRAINING", - "urn:oid:2.16.840.1.113883.5.8#HRESCH", - "urn:oid:2.16.840.1.113883.5.8#BIORCH", - "urn:oid:2.16.840.1.113883.5.8#CLINTRCH", - "urn:oid:2.16.840.1.113883.5.8#CLINTRCHNPC", - "urn:oid:2.16.840.1.113883.5.8#CLINTRCHPC", - "urn:oid:2.16.840.1.113883.5.8#PRECLINTRCH", - "urn:oid:2.16.840.1.113883.5.8#DSRCH", - "urn:oid:2.16.840.1.113883.5.8#POARCH", - "urn:oid:2.16.840.1.113883.5.8#TRANSRCH", - "urn:oid:2.16.840.1.113883.5.8#PATRQT", - "urn:oid:2.16.840.1.113883.5.8#FAMRQT", - "urn:oid:2.16.840.1.113883.5.8#PWATRNY", - "urn:oid:2.16.840.1.113883.5.8#SUPNWK", - "urn:oid:2.16.840.1.113883.5.8#PUBHLTH", - "urn:oid:2.16.840.1.113883.5.8#DISASTER", - "urn:oid:2.16.840.1.113883.5.8#THREAT", - "urn:oid:2.16.840.1.113883.5.8#HMARKT" - ], - - "CommunitySettings": [ - { - // TEFCA community - // Exchange Purpose code system (OID: 2.16.840.1.113883.3.7204.1.5.2.1) - // https://rce.sequoiaproject.org/wp-content/uploads/2025/01/SOP-Exchange-Purposes_CA-v2_v4-508.pdf - "Community": "urn:oid:2.16.840.1.113883.3.7204.1.5", - "ClientCredentialsExtensionsRequired": [ "hl7-b2b" ], - "AuthorizationCodeExtensionsRequired": [ "hl7-b2b-user" ], - "MaxPurposeOfUseCount": 1, - "AllowedPurposeOfUse": [ - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TRTMNT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-HCO", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-HCO-CC", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-HCO-HED", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-HCO-QM", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PH", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PH-ECR", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PH-ELR", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS", - "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-GOVDTRM" - ] - } - ] + "DefaultUserScopes": "openid user/*.rs user/*/read" }, "ConnectionStrings": { diff --git a/examples/Udap.Auth.Server/docs/README.md b/examples/Udap.Auth.Server/docs/README.md index 664808bf5..dce6561ef 100644 --- a/examples/Udap.Auth.Server/docs/README.md +++ b/examples/Udap.Auth.Server/docs/README.md @@ -1,7 +1,11 @@ -# Udap.Client +# Udap.Auth.Server ![UDAP logo](https://avatars.githubusercontent.com/u/77421324?s=48&v=4) -## 📦 This package +## UDAP Authorization Server Example -This the spirit of this package is an easy to use client. I have not put time in to ensure this is easy to use yet. Work in progress... \ No newline at end of file +A reference authorization server built on Duende IdentityServer with UDAP extensions. Supports Dynamic Client Registration, multi-community trust anchors, B2B and consumer-facing flows, Tiered OAuth, and authorization extension object enforcement. + +See the [Udap.Server docs](../../../Udap.Server/docs/README.md) for configuration details. + +Deployed at [securedcontrols.net](https://securedcontrols.net/.well-known/udap). diff --git a/examples/Udap.CA/.gcloudignore b/examples/Udap.CA/.gcloudignore deleted file mode 100644 index f3bb423e8..000000000 --- a/examples/Udap.CA/.gcloudignore +++ /dev/null @@ -1,11 +0,0 @@ -.gcloudignore -.git -.gitignore -bin\** -obj\** -.vs\** -Udap.CA.db* -launchSettings.json -host.docker.internal.pfx -appsettings.Development.json -appsettings.Production.json \ No newline at end of file diff --git a/examples/Udap.CA/App.razor b/examples/Udap.CA/App.razor deleted file mode 100644 index 843f201ab..000000000 --- a/examples/Udap.CA/App.razor +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
\ No newline at end of file diff --git a/examples/Udap.CA/DbContexts/UdapCaContext.cs b/examples/Udap.CA/DbContexts/UdapCaContext.cs deleted file mode 100644 index d52b579a5..000000000 --- a/examples/Udap.CA/DbContexts/UdapCaContext.cs +++ /dev/null @@ -1,90 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Microsoft.EntityFrameworkCore; -using Udap.CA.Entities; - -namespace Udap.CA.DbContexts; - -public interface IUdapCaContext : IDisposable -{ - DbSet Communities { get; set; } - DbSet RootCertificates { get; set; } - DbSet Anchors { get; set; } - DbSet IssuedCertificates { get; set; } - // DbSet Certifications { get; set; } - Task SaveChangesAsync(CancellationToken cancellationToken = default); -} - -public class UdapCaContext : DbContext, IUdapCaContext -{ - public UdapCaContext(DbContextOptions options) : base(options) - { - // this.Database.EnsureCreated(); - } - - public DbSet Communities { get; set; } - public DbSet RootCertificates { get; set; } - public DbSet Anchors { get; set; } - public DbSet IssuedCertificates { get; set; } - // public DbSet Certifications { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(community => - { - community.ToTable(name: "Communities"); //, table => table.IsTemporal()); // Not for SQLite :( - community.HasKey(e => e.Id); - - community.Property(x => x.Name).HasMaxLength(200); - - community.HasMany(c => c.RootCertificates) - .WithOne(r => r.Community) - .IsRequired(false) - .HasForeignKey(r => r.CommunityId) - .HasConstraintName("FK_RootCertificates_Community"); - }); - - modelBuilder.Entity(rootCert => - { - rootCert.ToTable("RootCertificates"); - rootCert.HasKey(e => e.Id); - - rootCert.HasMany(r => r.Anchors) - .WithOne(a => a.RootCertificate) - .IsRequired(false) - .HasForeignKey(a => a.RootCertificateId) - .HasConstraintName("FK_Anchors_RootCertificate"); - }); - - modelBuilder.Entity(anchor => - { - anchor.ToTable("Anchors"); - anchor.HasKey(e => e.Id); - - anchor.HasMany(r => r.IssuedCertificates) - .WithOne(a => a.Anchor) - .IsRequired(false) - .HasForeignKey(a => a.AnchorId) - .HasConstraintName("FK_IssuedCertificates_Anchor"); - }); - - // modelBuilder.Entity(certification => - // { - // certification.ToTable("Certifications"); - // certification.HasKey(e => e.Id); - // }); - - modelBuilder.Entity(issuedCert => - { - issuedCert.ToTable("IssuedCertificates"); - issuedCert.HasKey(e => e.Id); - }); - } -} diff --git a/examples/Udap.CA/Dockerfile b/examples/Udap.CA/Dockerfile deleted file mode 100644 index 287a419db..000000000 --- a/examples/Udap.CA/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 8080 -EXPOSE 443 - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src - -ENV GCPDeploy=true -COPY ["nuget.config", "."] -COPY ["Udap.CA.csproj", "."] -RUN dotnet restore "Udap.CA.csproj" -COPY . . - -RUN dotnet build "Udap.CA.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "Udap.CA.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENV ASPNETCORE_URLS=http://*:8080 -ENTRYPOINT ["dotnet", "Udap.CA.dll"] \ No newline at end of file diff --git a/examples/Udap.CA/Entities/Anchor.cs b/examples/Udap.CA/Entities/Anchor.cs deleted file mode 100644 index 8d19f2b47..000000000 --- a/examples/Udap.CA/Entities/Anchor.cs +++ /dev/null @@ -1,37 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -namespace Udap.CA.Entities; - -public class Anchor -{ - public int Id { get; set; } - public int RootCertificateId { get; set; } - - public bool Enabled { get; set; } - public string Subject { get; set; } = string.Empty; - public string SubjectAltName { get; set; } = string.Empty; - - public string CertificateRevocation { get; set; } = string.Empty; - public string CertificateAuthIssuerUri { get; set; } = string.Empty; - - /// - /// Base64 Der encoded - /// - public string X509Certificate { get; set; } = string.Empty; - public string Thumbprint { get; set; } = string.Empty; - public DateTime BeginDate { get; set; } - public DateTime EndDate { get; set; } - - public RootCertificate? RootCertificate { get; set; } - public virtual ICollection? IssuedCertificates { get; set; } - - //TODO: future - // public virtual ICollection AnchorCertifications { get; set; } -} \ No newline at end of file diff --git a/examples/Udap.CA/Entities/AnchorCertification.cs b/examples/Udap.CA/Entities/AnchorCertification.cs deleted file mode 100644 index 7192e20e3..000000000 --- a/examples/Udap.CA/Entities/AnchorCertification.cs +++ /dev/null @@ -1,30 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -// #region (c) 2022 Joseph Shook. All rights reserved. -// // /* -// // Authors: -// // Joseph Shook Joseph.Shook@Surescripts.com -// // -// // See LICENSE in the project root for license information. -// // */ -// #endregion -// -// namespace Udap.CA.Entities; -// -// /// -// /// This is an "associate" table, to map a many to many relationship -// /// -// public class AnchorCertification -// { -// public virtual Anchor Anchor { get; set; } -// public long AnchorId { get; set; } -// public virtual Certification Certification { get; set; } -// public long CertificationId { get; set; } -// } \ No newline at end of file diff --git a/examples/Udap.CA/Entities/Certification.cs b/examples/Udap.CA/Entities/Certification.cs deleted file mode 100644 index 51269280f..000000000 --- a/examples/Udap.CA/Entities/Certification.cs +++ /dev/null @@ -1,30 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -// #region (c) 2022 Joseph Shook. All rights reserved. -// // /* -// // Authors: -// // Joseph Shook Joseph.Shook@Surescripts.com -// // -// // See LICENSE in the project root for license information. -// // */ -// #endregion -// -// namespace Udap.CA.Entities; -// -// public class Certification -// { -// public int Id { get; set; } -// -// public string Name { get; set; } -// -// public virtual ICollection CommunityCertifications { get; set; } -// -// public virtual ICollection AnchorCertifications { get; set; } -// } \ No newline at end of file diff --git a/examples/Udap.CA/Entities/Community.cs b/examples/Udap.CA/Entities/Community.cs deleted file mode 100644 index e513a4900..000000000 --- a/examples/Udap.CA/Entities/Community.cs +++ /dev/null @@ -1,43 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -namespace Udap.CA.Entities; - -public class Community -{ - /// Initializes a new instance of the class. - public Community(string name, bool enabled) - { - Name = name; - Enabled = enabled; - } - - public int Id { get; set; } - - public string Name { get; set; } - - public bool Enabled { get; set; } - - /// - /// Generally a community has one anchor certificate. - /// But during rollover from an expired anchor certificate to a new certificate - /// there could be two for a short time. - /// - public ICollection? RootCertificates { get; set; } = new HashSet(); - - /// - /// A community may have named certifications. This is a list of possible - /// certifications. - /// - //TODO: future - // public ICollection? Certifications { get; set; } - - // public virtual ICollection CommunityCertifications { get; set; } - -} \ No newline at end of file diff --git a/examples/Udap.CA/Entities/CommunityCertification.cs b/examples/Udap.CA/Entities/CommunityCertification.cs deleted file mode 100644 index 4bb909a0e..000000000 --- a/examples/Udap.CA/Entities/CommunityCertification.cs +++ /dev/null @@ -1,30 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -// #region (c) 2022 Joseph Shook. All rights reserved. -// // /* -// // Authors: -// // Joseph Shook Joseph.Shook@Surescripts.com -// // -// // See LICENSE in the project root for license information. -// // */ -// #endregion -// -// namespace Udap.CA.Entities; -// -// /// -// /// This is an "associate" table, to map a many to many relationship -// /// -// public class CommunityCertification -// { -// public virtual Community Community { get; set; } -// public long CommunityId { get; set; } -// public virtual Certification Certification { get; set; } -// public long CertificationId { get; set; } -// } \ No newline at end of file diff --git a/examples/Udap.CA/Entities/IssuedCertificate.cs b/examples/Udap.CA/Entities/IssuedCertificate.cs deleted file mode 100644 index 179467e7a..000000000 --- a/examples/Udap.CA/Entities/IssuedCertificate.cs +++ /dev/null @@ -1,25 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -namespace Udap.CA.Entities; - -public class IssuedCertificate -{ - public int Id { get; set; } - public int AnchorId { get; set; } - public bool Enabled { get; set; } - public string Name { get; set; } = string.Empty; - public string Community { get; set; } = string.Empty; - - public string Certificate { get; set; } = string.Empty; - public DateTime BeginDate { get; set; } - public DateTime EndDate { get; set; } - - public Anchor? Anchor { get; set; } -} \ No newline at end of file diff --git a/examples/Udap.CA/Entities/RootCertificate.cs b/examples/Udap.CA/Entities/RootCertificate.cs deleted file mode 100644 index 783c0d29b..000000000 --- a/examples/Udap.CA/Entities/RootCertificate.cs +++ /dev/null @@ -1,75 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Udap.CA.Services; - -namespace Udap.CA.Entities; - -public class RootCertificate -{ - public RootCertificate(){} - - /// Initializes a new instance of the class. - public RootCertificate(Community community) - { - Community = community; - } - - public int Id { get; set; } - - public int CommunityId { get; set; } - - public bool Enabled { get; set; } - public string Name { get; set; } = string.Empty; - - /// - /// Certificate Bytes - /// If CertSecurityLevel , store in RSAPrivateKey pem format and X509Certificate format; - /// If CertSecurityLevel , store in X509Certificate pem format only; - /// - public string RSAPrivateKey { get; set; } = string.Empty; // TODO: An idea that is not implemented... - - public string X509Certificate { get; set; } = string.Empty; - - /// - /// Default to Software type. This will always be a PKCS12 format. - /// - public CertSecurityLevel CertSecurityLevel { get; set; } = CertSecurityLevel.Software; - - /// - /// This is just a sample. Sample is password in practice for this sample app. - /// If we created a real implementation this would we would use and HSM - /// and PKCS #11 protocol to communicate with it. Maybe I will find some time - /// to create an implementation. - /// - public string Secret { get; set; } = string.Empty; - - public string Thumbprint { get; set; } = string.Empty; - - /// - /// Place where public certificate is hosted. - /// - public string Url { get; set; } = string.Empty; - - public DateTime BeginDate { get; set; } - public DateTime EndDate { get; set; } - - public Community Community { get; set; } - - /// - /// Generally a community has one Anchor. - /// But during rollover from an expired anchor to a new anchor - /// there could be two for a short time. - /// - public ICollection? Anchors { get; set; } - - - -} - diff --git a/examples/Udap.CA/Mappers/AnchorMapper.cs b/examples/Udap.CA/Mappers/AnchorMapper.cs deleted file mode 100644 index ae07c032f..000000000 --- a/examples/Udap.CA/Mappers/AnchorMapper.cs +++ /dev/null @@ -1,70 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography.X509Certificates; -using Udap.CA.Entities; - -namespace Udap.CA.Mappers; - -public static class AnchorMapper -{ - /// - /// Maps an entity to a view model. - /// - /// The entity. - /// - public static ViewModel.Anchor ToModel(this Anchor entity) - { - return new ViewModel.Anchor - { - Id = entity.Id, - Enabled = entity.Enabled, - Subject = entity.Subject, - SubjectAltName = entity.SubjectAltName, - CertificateRevocation = entity.CertificateRevocation, - CertificateAuthIssuerUri = entity.CertificateAuthIssuerUri, - Certificate = !string.IsNullOrEmpty(entity.X509Certificate) - ? new X509Certificate2(Convert.FromBase64String( - entity.X509Certificate - .Replace("-----BEGIN CERTIFICATE-----", "") - .Replace("-----END CERTIFICATE-----", "") - .Trim())) - : null, - Thumbprint = entity.Thumbprint, - BeginDate = entity.BeginDate, - EndDate = entity.EndDate, - RootCertificateId = entity.RootCertificateId - }; - } - - /// - /// Maps a view model to an entity. - /// - /// The view model. - /// - public static Anchor ToEntity(this ViewModel.Anchor model) - { - return new Anchor - { - Id = (int)model.Id, - Enabled = model.Enabled, - Subject = model.Subject, - SubjectAltName = model.SubjectAltName, - CertificateRevocation = model.CertificateRevocation, - CertificateAuthIssuerUri = model.CertificateAuthIssuerUri, - X509Certificate = model.Certificate != null - ? Convert.ToBase64String(model.Certificate.Export(X509ContentType.Cert)) - : string.Empty, - Thumbprint = model.Thumbprint ?? string.Empty, - BeginDate = model.BeginDate ?? DateTime.MinValue, - EndDate = model.EndDate ?? DateTime.MinValue, - RootCertificateId = model.RootCertificateId - }; - } -} diff --git a/examples/Udap.CA/Mappers/CommnityMapper.cs b/examples/Udap.CA/Mappers/CommnityMapper.cs deleted file mode 100644 index 6c0735963..000000000 --- a/examples/Udap.CA/Mappers/CommnityMapper.cs +++ /dev/null @@ -1,65 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography.X509Certificates; -using Udap.CA.Entities; - -namespace Udap.CA.Mappers -{ - public static class CommunityMapper - { - /// - /// Maps an entity to a view model. - /// - /// The entity. - /// - public static ViewModel.Community ToViewModel(this Community entity) - { - return new ViewModel.Community - { - Id = entity.Id, - Name = entity.Name, - Enabled = entity.Enabled, - RootCertificates = entity.RootCertificates?.Select(rc => new ViewModel.RootCertificate - { - Id = rc.Id, - CommunityId = rc.CommunityId, - Name = rc.Name, - Url = rc.Url, - Thumbprint = rc.Thumbprint, - BeginDate = rc.BeginDate, - EndDate = rc.EndDate, - Enabled = rc.Enabled, - Certificate = X509Certificate2.CreateFromPem(rc.X509Certificate), - }).ToHashSet() ?? new HashSet() - }; - } - - /// - /// Maps a view model to an entity. - /// - /// The view model. - /// - public static Community ToEntity(this ViewModel.Community model) - { - return new Community(model.Name, model.Enabled) - { - Id = model.Id - }; - } - - /// - /// Maps a collection of entities to a collection of view models. - /// - public static ICollection ToViewModels(this IEnumerable entities) - { - return entities.Select(e => e.ToViewModel()).ToList(); - } - } -} diff --git a/examples/Udap.CA/Mappers/RootCertificateMapper.cs b/examples/Udap.CA/Mappers/RootCertificateMapper.cs deleted file mode 100644 index 9ab5276de..000000000 --- a/examples/Udap.CA/Mappers/RootCertificateMapper.cs +++ /dev/null @@ -1,77 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Udap.CA.Entities; -using Udap.Common; - -namespace Udap.CA.Mappers; - -public static class RootCertificateMapper -{ - /// - /// Maps an entity to a view model. - /// - /// The entity. - /// - public static ViewModel.RootCertificate ToViewModel(this RootCertificate entity) - { - return new ViewModel.RootCertificate - { - Id = entity.Id, - CommunityId = entity.CommunityId, - Enabled = entity.Enabled, - Name = entity.Name, - Certificate = X509Certificate2.CreateFromPem(entity.X509Certificate), - Thumbprint = entity.Thumbprint, - Url = entity.Url, - BeginDate = entity.BeginDate, - EndDate = entity.EndDate - }; - } - - /// - /// Maps a view model to an entity. - /// - /// The view model. - /// - public static RootCertificate ToEntity(this ViewModel.RootCertificate model) - { - return new RootCertificate - { - Id = model.Id, - CommunityId = model.CommunityId, - Enabled = model.Enabled, - Name = model.Name, - RSAPrivateKey = model.Certificate != null - ? PemEncoding.WriteString( - PemLabels.RsaPrivateKey, - model.Certificate.Export(X509ContentType.Pkcs12, model.Secret)) - : string.Empty, - X509Certificate = model.Certificate != null - ? PemEncoding.WriteString( - PemLabels.X509Certificate, - model.Certificate.Export(X509ContentType.Cert)) - : string.Empty, - Thumbprint = model.Thumbprint ?? string.Empty, - Url = model.Url, - BeginDate = model.BeginDate ?? DateTime.Now, - EndDate = model.EndDate ?? DateTime.Now.AddYears(10) - }; - } - - /// - /// Maps a collection of entities to a collection of view models. - /// - public static ICollection ToViewModels(this IEnumerable entities) - { - return entities.Select(e => e.ToViewModel()).ToList(); - } -} diff --git a/examples/Udap.CA/Pages/CommunityList.razor b/examples/Udap.CA/Pages/CommunityList.razor deleted file mode 100644 index 4e955c16c..000000000 --- a/examples/Udap.CA/Pages/CommunityList.razor +++ /dev/null @@ -1,157 +0,0 @@ -@page "/CommunityList" - - -@inject ISnackbar Snackbar - - - - - - Communities - - - - - - - - - - - - Name - Enabled - Root Certificates - - - - @if (context.Id == 0) - { - @context.Name - } - else - { - @context.Name - } - - - @context.RootCertificates.Count() - - @if (context.RootCertificates.Any()) - { - - } - - - - - @if (context.Id != 0) - { - - } - - - - - @if (context.ShowRootCertificates) - { - - - - - - - - - - - - - Name - Enabled - Begin Date - End Date - Thumbprint - - - @RootCertContext.Name - - - @RootCertContext.Enabled - @RootCertContext.BeginDate - @RootCertContext.EndDate - @RootCertContext.Thumbprint - - - - - - - } - - - - - - - - - - - - - - - -
-            @ex.Message
-      
-
-
- - - - @foreach (var message in _editEvents) - { - @message - } - @if (_editEvents.Count > 0) - { -
- - Clear event log -
- } -
-
\ No newline at end of file diff --git a/examples/Udap.CA/Pages/CommunityList.razor.cs b/examples/Udap.CA/Pages/CommunityList.razor.cs deleted file mode 100644 index 626bda8a9..000000000 --- a/examples/Udap.CA/Pages/CommunityList.razor.cs +++ /dev/null @@ -1,179 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.JSInterop; -using Udap.CA.Services; -using Udap.CA.Services.State; -using Udap.CA.ViewModel; - -namespace Udap.CA.Pages; - -public partial class CommunityList -{ - - [Inject] private CommunityService CommunityService { get; set; } = null!; - [Inject] private RootCertificateService RootCertificateService { get; set; } = null!; - [Inject] CommunityState CommunityState { get; set; } = null!; - [Inject] private IJSRuntime Js { get; set; } = null!; - ErrorBoundary? ErrorBoundary { get; set; } - private readonly List _editEvents = new(); - private string _searchString = ""; - private Community? _communityBeforeEdit; - private ICollection _communities = new List(); - private ICollection _rootCertificates = new List(); - - protected override async Task OnInitializedAsync() - { - _communities = await CommunityService.Get(); - _rootCertificates = await RootCertificateService.Get(); - - CommunityState.SetState(_rootCertificates); - } - - protected override void OnParametersSet() - { - ErrorBoundary?.Recover(); - } - - private void ClearEventLog() - { - _editEvents.Clear(); - } - - private void AddEditionEvent(string message) - { - _editEvents.Add(message); - StateHasChanged(); - } - - private void BackupItem(object community) - { - try - { - _communityBeforeEdit = new() - { - Id = ((Community)community).Id, - Name = ((Community)community).Name, - Enabled = ((Community)community).Enabled, - }; - AddEditionEvent($"CommunityEditPreview event: made a backup of Community {((Community)community).Name}"); - } - catch - { - throw; - } - - _communityRowIsInEdit = true; - } - - private void ItemHasBeenCommitted(object community) - { - var communityViewModel = (Community)community; - - if (communityViewModel.Id > 0) - { - // While this is a sync over async problem it at least will not wrap the erro in AggregateException - // https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md#warning-deadlocks - // The MudBlazor table does not RowEditCommit is not async. Here is a work around https://github.com/MudBlazor/MudBlazor/issues/3230 - // I am just not going to spend the time right now. And if I come back to this maybe I try a PR to the Mudblazor project. - Task.Run(() => CommunityService.Update(communityViewModel)).GetAwaiter().GetResult(); - AddEditionEvent($"CommunityEditCommit event: Updating anchor {communityViewModel.Name} committed"); - } - else - { - // FYI there is a work around to this sync over async - var resultAnchor = Task.Run(() => CommunityService.Create(communityViewModel)).GetAwaiter().GetResult(); - - if (_communityRowInEdit != null) - { - _communityRowInEdit.Id = resultAnchor.Id; - } - - AddEditionEvent($"CommunityEditCommit event: Changes to Community {((Community)community).Name} committed"); - } - - _communityRowIsInEdit = false; - StateHasChanged(); - } - - private void ResetItemToOriginalValues(object community) - { - if ( community == null ) { - AddEditionEvent($"CommunityEditCancel event: Null community. Probably related data open."); - return; - } - - if (_communityBeforeEdit != null) - { - ((Community)community).Id = _communityBeforeEdit.Id; - ((Community)community).Name = _communityBeforeEdit.Name; - ((Community)community).Enabled = _communityBeforeEdit.Enabled; - } - - AddEditionEvent($"CommunityEditCancel event: Editing of Community {((Community)community).Name} cancelled"); - - _communityRowIsInEdit = false; - - } - - private Community? _communityRowInEdit; - private bool _communityRowIsInEdit = false; - - private async Task AddCommunity() - { - _communityRowInEdit = new Community() - { - }; - - _communities.Add(_communityRowInEdit); - await Task.Delay(1); - StateHasChanged(); - - await Js.InvokeVoidAsync("UdapCA.setFocus", "CommunityId:0"); - - StateHasChanged(); - } - - private async Task DeleteRootCommunity(Community community) - { - if (await Js.InvokeAsync("confirm", $"Do you want to delete the {community.Name} Record?")) - { - var result = await CommunityService.Delete(community.Id); - - if (true) - { - _communityRowIsInEdit = false; - _communities.Remove(community); - _communityRowInEdit = null; - StateHasChanged(); - return true; - } - } - return false; - } - - public void ShowRootCertificates(Community community) - { - community.ShowRootCertificates = true; - } - - - - private bool FilterFunc(Community community) - { - if (string.IsNullOrWhiteSpace(_searchString)) - return true; - if (community.Name.Contains(_searchString, StringComparison.OrdinalIgnoreCase)) - return true; - return false; - } -} - diff --git a/examples/Udap.CA/Pages/Error.cshtml b/examples/Udap.CA/Pages/Error.cshtml deleted file mode 100644 index efa4a3e3a..000000000 --- a/examples/Udap.CA/Pages/Error.cshtml +++ /dev/null @@ -1,42 +0,0 @@ -@page -@model Udap.CA.Pages.ErrorModel - - - - - - - - Error - - - - - -
-
-

Error.

-

An error occurred while processing your request.

- - @if (Model.ShowRequestId) - { -

- Request ID: @Model.RequestId -

- } - -

Development Mode

-

- Swapping to the Development environment displays detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

-
-
- - - diff --git a/examples/Udap.CA/Pages/Error.cshtml.cs b/examples/Udap.CA/Pages/Error.cshtml.cs deleted file mode 100644 index a7b0fb4c3..000000000 --- a/examples/Udap.CA/Pages/Error.cshtml.cs +++ /dev/null @@ -1,34 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Diagnostics; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; - -namespace Udap.CA.Pages; -[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] -[IgnoreAntiforgeryToken] -public class ErrorModel : PageModel -{ - public string? RequestId { get; set; } - - public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - private readonly ILogger _logger; - - public ErrorModel(ILogger logger) - { - _logger = logger; - } - - public void OnGet() - { - RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; - } -} diff --git a/examples/Udap.CA/Pages/Index.razor b/examples/Udap.CA/Pages/Index.razor deleted file mode 100644 index 7994d546f..000000000 --- a/examples/Udap.CA/Pages/Index.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Index - -Hello, world! -Welcome to your new app, powered by MudBlazor! -You can find documentation and examples on our website here: www.mudblazor.com diff --git a/examples/Udap.CA/Pages/RootCertEditComponent.razor b/examples/Udap.CA/Pages/RootCertEditComponent.razor deleted file mode 100644 index 1600d6692..000000000 --- a/examples/Udap.CA/Pages/RootCertEditComponent.razor +++ /dev/null @@ -1,134 +0,0 @@ -@page "/RootCerts" -@using System.Security.Cryptography.X509Certificates - -@inject ISnackbar Snackbar - - - - - - Root Certificates - - - - - - - - - - - - - Subject - Enabled - Thumbprint - Certificate Name - - - - @if (context.Id == 0) - { - @context.Name - } - else - { - @context.Name - } - - @context.Certificate?.GetNameInfo(X509NameType.SimpleName, false) - @context.Thumbprint - - @if (context.Id != 0) - { - - } - - - - - - - @context.Thumbprint - - - @if (context.Certificate != null) - { - - @context.Certificate?.GetNameInfo(X509NameType.SimpleName, false) - - } - - - - - - - - @if (_rootFormActive && RootCertificateRowInEdit != null) - { - - - - - - - - - - - - - - Generate Root CA - - - } - - - -
-            @ex.Message
-      
-
-
- - - - @foreach (var message in _editEvents) - { - @message - } - @if (_editEvents.Count > 0) - { -
- - Clear event log -
- } -
-
- diff --git a/examples/Udap.CA/Pages/RootCertEditComponent.razor.cs b/examples/Udap.CA/Pages/RootCertEditComponent.razor.cs deleted file mode 100644 index 45ed3f05a..000000000 --- a/examples/Udap.CA/Pages/RootCertEditComponent.razor.cs +++ /dev/null @@ -1,195 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.JSInterop; -using MudBlazor; -using Udap.CA.Services; -using Udap.CA.Services.State; -using Udap.CA.ViewModel; - -namespace Udap.CA.Pages; - -public partial class RootCertEditComponent -{ - [Inject] RootCertificateService RootCertService { get; set; } = null!; - - [Inject] CommunityState CommunityState { get; set; } = null!; - - [Inject] private IJSRuntime Js { get; set; } = null!; - - ErrorBoundary? ErrorBoundary { get; set; } - - private List _editEvents = new(); - private RootCertificate _rootCertificateBeforeEdit = new(); - private ICollection? _rootCertificates; - private RootCertificate? RootCertificateRowInEdit { get; set; } - private bool _rootCertificateRowIsInEditMode; - private bool _rootFormActive; - - - protected override void OnInitialized() - { - base.OnInitialized(); - - _rootCertificates = CommunityState.RootCertificates; - } - - protected override void OnParametersSet() - { - ErrorBoundary?.Recover(); - } - - private void ClearEventLog() - { - _editEvents.Clear(); - } - - private void AddEditionEvent(string message) - { - _editEvents.Add(message); - StateHasChanged(); - } - - private void BackupItem(object rootCertificate) - { - try - { - _rootCertificateBeforeEdit = new() - { - Id = ((RootCertificate)rootCertificate).Id, - Name = ((RootCertificate)rootCertificate).Name, - Enabled = ((RootCertificate)rootCertificate).Enabled, - Certificate = ((RootCertificate)rootCertificate).Certificate, - Thumbprint = ((RootCertificate)rootCertificate).Thumbprint - }; - - - AddEditionEvent($"RowEditPreview event: made a backup of Community {((RootCertificate)rootCertificate).Name}"); - } - catch - { - throw; - } - - _rootCertificateRowIsInEditMode = true; - } - - private void ItemHasBeenCommitted(object rootCertificate) - { - var rootCertificateViewModel = (RootCertificate)rootCertificate; - - if (rootCertificateViewModel.Id > 0) - { - UpdateRecord(rootCertificateViewModel); - } - else - { - rootCertificateViewModel.BeginDate = rootCertificateViewModel.Certificate?.NotBefore; - rootCertificateViewModel.EndDate = rootCertificateViewModel.Certificate?.NotAfter; - var resultRootCertificate = RootCertService.Create(rootCertificateViewModel).GetAwaiter().GetResult(); - AddEditionEvent($"RowEditCommit event: Adding root certificate {rootCertificateViewModel.Name} committed"); - if (RootCertificateRowInEdit != null) - RootCertificateRowInEdit.Id = resultRootCertificate.Id; //bind up the new id... - } - - _rootCertificateRowIsInEditMode = false; - StateHasChanged(); - } - - private void UpdateRecord(RootCertificate rootCertificateView) - { - RootCertService.Update(rootCertificateView).GetAwaiter().GetResult(); - AddEditionEvent($"RowEditCommit event: Updating root certificate {rootCertificateView.Name} committed"); - } - - - private void ResetItemToOriginalValues(object rootCertificate) - { - try - { - if (((RootCertificate)rootCertificate).Id == 0) - { - _rootCertificates?.Remove((RootCertificate)rootCertificate); - AddEditionEvent($"RowEditCancel event: Editing of new RootCertificate cancelled"); - } - - ((RootCertificate)rootCertificate).Id = _rootCertificateBeforeEdit.Id; - ((RootCertificate)rootCertificate).Name = _rootCertificateBeforeEdit.Name; - ((RootCertificate)rootCertificate).Enabled = _rootCertificateBeforeEdit.Enabled; - ((RootCertificate)rootCertificate).Certificate = _rootCertificateBeforeEdit.Certificate; - ((RootCertificate)rootCertificate).Thumbprint = _rootCertificateBeforeEdit.Thumbprint; - - AddEditionEvent($"RowEditCancel event: Editing of RootCertificate {((RootCertificate)rootCertificate).Name} cancelled"); - } - catch - { - throw; - } - - _rootCertificateRowIsInEditMode = false; - StateHasChanged(); - } - - private async Task AddRootCertificate() - { - RootCertificateRowInEdit = new RootCertificate() {}; - _rootFormActive = true; - await Task.Delay(1); - StateHasChanged(); - } - - private async Task DeleteRootCertificate(RootCertificate rootCertificate) - { - if (await Js.InvokeAsync("confirm", $"Do you want to delete the {rootCertificate.Name} Record?")) - { - var result = await RootCertService.Delete(rootCertificate.Id); - - if (result) - { - _rootCertificateRowIsInEditMode = false; - _rootCertificates?.Remove(rootCertificate); - RootCertificateRowInEdit = null; - StateHasChanged(); - return true; - } - } - return false; - } - - MudForm? _rootCertForm; - - private async Task Submit() - { - await _rootCertForm?.Validate()!; - - if (_rootCertForm.IsValid) - { - var generator = new CertificateUtilities(); - - if (RootCertificateRowInEdit != null) - { - var rootCert = generator.GenerateRootCA(RootCertificateRowInEdit.Name); - RootCertificateRowInEdit.Certificate = rootCert; - RootCertificateRowInEdit.Thumbprint = rootCert.Thumbprint; - - if (CommunityState.Community != null) - { - RootCertificateRowInEdit.CommunityId = CommunityState.Community.Id; - } - - var cert = await RootCertService.Create(RootCertificateRowInEdit); - Snackbar.Add($"{cert.Name} certificate generated!"); - - _rootFormActive = false; - } - } - } -} \ No newline at end of file diff --git a/examples/Udap.CA/Pages/_Host.cshtml b/examples/Udap.CA/Pages/_Host.cshtml deleted file mode 100644 index 4fe2b87ed..000000000 --- a/examples/Udap.CA/Pages/_Host.cshtml +++ /dev/null @@ -1,9 +0,0 @@ -@page "/" -@namespace Udap.CA.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@{ - Layout = "_Layout"; -} - - - \ No newline at end of file diff --git a/examples/Udap.CA/Pages/_Layout.cshtml b/examples/Udap.CA/Pages/_Layout.cshtml deleted file mode 100644 index 8629fd89a..000000000 --- a/examples/Udap.CA/Pages/_Layout.cshtml +++ /dev/null @@ -1,31 +0,0 @@ -@using Microsoft.AspNetCore.Components.Web -@namespace Udap.CA.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers - - - - - - - - - - - - - @RenderBody() - -
- - An error has occurred. This application may no longer respond until reloaded. - - - An unhandled exception has occurred. See browser dev tools for details. - - Reload - 🗙 -
- - - - diff --git a/examples/Udap.CA/Program.cs b/examples/Udap.CA/Program.cs deleted file mode 100644 index a8ab9159a..000000000 --- a/examples/Udap.CA/Program.cs +++ /dev/null @@ -1,94 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Microsoft.AspNetCore.Hosting.StaticWebAssets; -using Microsoft.EntityFrameworkCore; -using MudBlazor.Services; -using Udap.CA.DbContexts; -using Udap.CA.Services; -using Udap.CA.Services.State; -using Serilog; - -Log.Logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateBootstrapLogger(); - -Log.Information("Starting up"); - -try -{ - var builder = WebApplication.CreateBuilder(args); - - builder.Host.UseSerilog((ctx, lc) => lc - .WriteTo.Console( - outputTemplate: - "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}") - .Enrich.FromLogContext() - .ReadFrom.Configuration(ctx.Configuration)); - - builder.Configuration.AddJsonFile("/secret/udap-ca_appsettings", true, false); - - StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); - - var provider = builder.Configuration.GetValue("provider", "SqlServer"); - var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); - - - // Add services to the container. - builder.Services.AddRazorPages(); - builder.Services.AddServerSideBlazor(); - builder.Services.AddMudServices(); - _ = provider switch - { - "sqlite" => builder.Services.AddDbContext( - options => options.UseSqlite(connectionString) - .LogTo(Console.WriteLine, LogLevel.Information)), - - "SqlServer" => builder.Services.AddDbContext( - options => options.UseSqlServer(connectionString) - .LogTo(Console.WriteLine, LogLevel.Information)), - - _ => throw new Exception($"Unsupported provider: {provider}") - }; - - - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - var app = builder.Build(); - -// Configure the HTTP request pipeline. - if (!app.Environment.IsDevelopment()) - { - app.UseExceptionHandler("/Error"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); - } - - app.UseHttpsRedirection(); - - app.UseStaticFiles(); - - app.UseRouting(); - - app.MapBlazorHub(); - app.MapFallbackToPage("/_Host"); - - app.Run(); -} -catch (Exception ex) -{ - Log.Fatal(ex, "Unhandled exception"); -} -finally -{ - Log.Information("Shut down completed"); - Log.CloseAndFlush(); -} \ No newline at end of file diff --git a/examples/Udap.CA/Properties/launchSettings.json b/examples/Udap.CA/Properties/launchSettings.json deleted file mode 100644 index 1ad17e26b..000000000 --- a/examples/Udap.CA/Properties/launchSettings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "profiles": { - "Udap.CA sqlite": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:6061;http://localhost:6060" - }, - "Udap Local SqlDb": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "LocalSqlDb": "true" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:6061;http://localhost:6060" - }, - "GCP SqlDb": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "GCPDeploy": "true" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:6061;http://localhost:6060" - } - } -} \ No newline at end of file diff --git a/examples/Udap.CA/Services/AuthorityInformationAccessBuilder.cs b/examples/Udap.CA/Services/AuthorityInformationAccessBuilder.cs deleted file mode 100644 index e3f16cf0b..000000000 --- a/examples/Udap.CA/Services/AuthorityInformationAccessBuilder.cs +++ /dev/null @@ -1,72 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Formats.Asn1; -using System.Security.Cryptography.X509Certificates; - -namespace Udap.CA.Services -{ - public sealed class AuthorityInformationAccessBuilder - { - private List _encodedUrls = new List(); - private readonly List _encodedSequences = new List(); - /// - /// Adding ObjectIdentifier (OID) 1.3.6.1.5.5.7.48.2 - /// - /// - public void AdCertificateAuthorityIssuerUri(Uri uri) - { - if (uri == null) - throw new ArgumentNullException(nameof(uri)); - - AsnWriter writer = new AsnWriter(AsnEncodingRules.DER); - - writer.WriteObjectIdentifier("1.3.6.1.5.5.7.48.2"); - _encodedUrls.Add(writer.Encode()); - - writer = new AsnWriter(AsnEncodingRules.DER); - - writer.WriteCharacterString( - UniversalTagNumber.IA5String, - uri.AbsoluteUri, - new Asn1Tag(TagClass.ContextSpecific, 6)); - - _encodedUrls.Add(writer.Encode()); - - writer = new AsnWriter(AsnEncodingRules.DER); - using (writer.PushSequence()) - { - foreach (byte[] encodedName in _encodedUrls) - { - writer.WriteEncodedValue(encodedName); - } - } - - _encodedSequences.Add(writer.Encode()); - } - - public X509Extension Build(bool critical = false) - { - AsnWriter writer = new AsnWriter(AsnEncodingRules.DER); - - using (writer.PushSequence()) - { - foreach (byte[] encodedName in _encodedSequences) - { - writer.WriteEncodedValue(encodedName); - } - } - return new X509Extension( - // Oids AuthorityInfoAccessSyntax, - "1.3.6.1.5.5.7.1.1", - writer.Encode(), - critical); - } - } -} diff --git a/examples/Udap.CA/Services/CertSecurityLevel.cs b/examples/Udap.CA/Services/CertSecurityLevel.cs deleted file mode 100644 index 1cbc933c1..000000000 --- a/examples/Udap.CA/Services/CertSecurityLevel.cs +++ /dev/null @@ -1,16 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -namespace Udap.CA.Services; - -public enum CertSecurityLevel : byte -{ - Software = 0, - Fips1403 = 1 -} \ No newline at end of file diff --git a/examples/Udap.CA/Services/CertificateUtilities.cs b/examples/Udap.CA/Services/CertificateUtilities.cs deleted file mode 100644 index c80a33f37..000000000 --- a/examples/Udap.CA/Services/CertificateUtilities.cs +++ /dev/null @@ -1,250 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; - -namespace Udap.CA.Services; - -public class CertificateUtilities : IDisposable -{ - private X509Certificate2? _certificate; - private readonly RSA _rsaKey; - - public CertificateUtilities(RSA? rsa = null) - { - if (rsa != null) - { - _rsaKey = rsa; - } - - _rsaKey = RSA.Create(4096); - } - - public X509Certificate2 GenerateRootCA(string subject) - { - var parentReq = new CertificateRequest( - subject, - _rsaKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - parentReq.CertificateExtensions.Add( - new X509BasicConstraintsExtension(true, false, 0, true)); - - parentReq.CertificateExtensions.Add( - new X509KeyUsageExtension( - X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign, - true)); - - parentReq.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection - { - new Oid("1.3.6.1.5.5.7.3.2"), // TLS Client auth - new Oid("1.3.6.1.5.5.7.3.1"), // TLS Server auth - new Oid("1.3.6.1.5.5.7.3.8") // Time Stamping - }, - true)); - - parentReq.CertificateExtensions.Add( - new X509SubjectKeyIdentifierExtension(parentReq.PublicKey, false)); - - _certificate = parentReq.CreateSelfSigned( - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow.AddYears(10)); - - return _certificate; - } - - public X509Certificate2 GenerateIntermediate( - string subject, - Uri subjectAltName, - Uri certificateRevocation, - Uri certificateAuthIssuerUri, - X509Certificate2 issuerCertificate) - { - var intermediateRequest = new CertificateRequest( - subject, - _rsaKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - // Referred to as intermediate Cert or Anchor - intermediateRequest.CertificateExtensions.Add( - new X509BasicConstraintsExtension(true, false, 0, true)); - - intermediateRequest.CertificateExtensions.Add( - new X509KeyUsageExtension( - X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign, - true)); - - intermediateRequest.CertificateExtensions.Add( - new X509SubjectKeyIdentifierExtension(intermediateRequest.PublicKey, false)); - - AddAuthorityKeyIdentifier(issuerCertificate, intermediateRequest); - intermediateRequest.CertificateExtensions.Add(MakeCdp(certificateRevocation)); - - var subAltNameBuilder = new SubjectAlternativeNameBuilder(); - subAltNameBuilder.AddUri(subjectAltName); - var x509Extension = subAltNameBuilder.Build(); - intermediateRequest.CertificateExtensions.Add(x509Extension); - - var authorityInfoAccessBuilder = new AuthorityInformationAccessBuilder(); - authorityInfoAccessBuilder.AdCertificateAuthorityIssuerUri(certificateAuthIssuerUri); - var aiaExtension = authorityInfoAccessBuilder.Build(); - intermediateRequest.CertificateExtensions.Add(aiaExtension); - - _certificate = intermediateRequest.Create( - issuerCertificate, - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow.AddYears(5), - new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); - - return _certificate.CopyWithPrivateKey(_rsaKey); - } - - // TODO: Need to be able to generate multiple subjectAltNames, maybe. - public X509Certificate2 GenerateEndCert(string subject, - Uri subjectAltName, - Uri certificateRevocation, - Uri certificateAuthIssuerUri, - X509Certificate2 issuerCertificate) - { - var sureFhirLabsClientReq = new CertificateRequest( - subject, - _rsaKey, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - sureFhirLabsClientReq.CertificateExtensions.Add( - new X509BasicConstraintsExtension(false, false, 0, true)); - - sureFhirLabsClientReq.CertificateExtensions.Add( - new X509KeyUsageExtension( - X509KeyUsageFlags.DigitalSignature, - false)); - - sureFhirLabsClientReq.CertificateExtensions.Add( - new X509SubjectKeyIdentifierExtension(sureFhirLabsClientReq.PublicKey, false)); - - AddAuthorityKeyIdentifier(issuerCertificate, sureFhirLabsClientReq); - - sureFhirLabsClientReq.CertificateExtensions.Add(MakeCdp(certificateRevocation)); - - var subAltNameBuilder = new SubjectAlternativeNameBuilder(); - - // - // Just here for what if scenario for now. - // - //subAltNameBuilder.AddUri(new Uri("http://localhost")); //Same as iss claim - - subAltNameBuilder.AddUri(subjectAltName); //Same as iss claim - - var x509Extension = subAltNameBuilder.Build(); - sureFhirLabsClientReq.CertificateExtensions.Add(x509Extension); - - var authorityInfoAccessBuilder = new AuthorityInformationAccessBuilder(); - authorityInfoAccessBuilder.AdCertificateAuthorityIssuerUri(certificateAuthIssuerUri); - var aiaExtension = authorityInfoAccessBuilder.Build(); - sureFhirLabsClientReq.CertificateExtensions.Add(aiaExtension); - - var clientCert = sureFhirLabsClientReq.Create( - issuerCertificate, - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow.AddYears(2), - new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); - - return clientCert.CopyWithPrivateKey(_rsaKey); - } - - private static X509Extension MakeCdp(Uri uri) - { - // - // urls less than 119 char solution. - // From Bartonjs of course. - // - // https://stackoverflow.com/questions/60742814/add-crl-distribution-points-cdp-extension-to-x509certificate2-certificate - // - // From Crypt32: .NET doesn't support CDP extension. You have to use 3rd party libraries for that. BC is ok if it works for you. - // Otherwise write you own. :) - // - - byte[] encodedUrl = Encoding.ASCII.GetBytes(uri.AbsoluteUri); - - if (encodedUrl.Length > 119) - { - throw new NotSupportedException(); - } - - byte[] payload = new byte[encodedUrl.Length + 10]; - int offset = 0; - payload[offset++] = 0x30; - payload[offset++] = (byte)(encodedUrl.Length + 8); - payload[offset++] = 0x30; - payload[offset++] = (byte)(encodedUrl.Length + 6); - payload[offset++] = 0xA0; - payload[offset++] = (byte)(encodedUrl.Length + 4); - payload[offset++] = 0xA0; - payload[offset++] = (byte)(encodedUrl.Length + 2); - payload[offset++] = 0x86; - payload[offset++] = (byte)(encodedUrl.Length); - Buffer.BlockCopy(encodedUrl, 0, payload, offset, encodedUrl.Length); - - return new X509Extension("2.5.29.31", payload, critical: false); - } - - private static void AddAuthorityKeyIdentifier(X509Certificate2 caCert, CertificateRequest anchorReq) - { - // - // Found way to generate intermediate below - // - // https://github.com/rwatjen/AzureIoTDPSCertificates/blob/711429e1b6dee7857452233a73f15c22c2519a12/src/DPSCertificateTool/CertificateUtil.cs#L69 - // https://blog.rassie.dk/2018/04/creating-an-x-509-certificate-chain-in-c/ - // - - - var issuerSubjectKey = caCert.Extensions["2.5.29.14"]?.RawData; - if (issuerSubjectKey != null) - { - var segment = new ArraySegment(issuerSubjectKey, 2, issuerSubjectKey.Length - 2); - var authorityKeyIdentifier = new byte[segment.Count + 4]; - // these bytes define the "KeyID" part of the AuthorityKeyIdentifier - authorityKeyIdentifier[0] = 0x30; - authorityKeyIdentifier[1] = 0x16; - authorityKeyIdentifier[2] = 0x80; - authorityKeyIdentifier[3] = 0x14; - segment.CopyTo(authorityKeyIdentifier, 4); - anchorReq.CertificateExtensions.Add(new X509Extension("2.5.29.35", authorityKeyIdentifier, false)); - } - } - - private void Dispose(bool disposing) - { - if (disposing) - { - _certificate?.Dispose(); - _rsaKey.Dispose(); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// Allows an object to try to free resources and perform other cleanup operations before it is reclaimed by garbage collection. - ~CertificateUtilities() - { - Dispose(false); - } -} diff --git a/examples/Udap.CA/Services/CommunityService.cs b/examples/Udap.CA/Services/CommunityService.cs deleted file mode 100644 index ef745e7fc..000000000 --- a/examples/Udap.CA/Services/CommunityService.cs +++ /dev/null @@ -1,80 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Microsoft.EntityFrameworkCore; -using Udap.CA.DbContexts; -using Udap.CA.Mappers; - -namespace Udap.CA.Services; - -public class CommunityService -{ - private IUdapCaContext _dbContext; - private ILogger _logger; - - public CommunityService(IUdapCaContext dbContext, ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - public async Task> Get(CancellationToken token = default) - { - var communties = await _dbContext.Communities - .Include(c => c.RootCertificates) - .ToListAsync(cancellationToken: token); - - return communties.ToViewModels(); - } - - public async Task Create(ViewModel.Community community, CancellationToken token = default) - { - var entity = community.ToEntity(); - _dbContext.Communities.Add(entity); - await _dbContext.SaveChangesAsync(token); - - return entity.ToViewModel(); - } - - public async Task Update(ViewModel.Community community, CancellationToken token = default) - { - var entity = await _dbContext.Communities - .Where(c => c.Id == community.Id) - .SingleOrDefaultAsync(cancellationToken: token); - - if (entity == null) - { - _logger.LogDebug($"No Community Id {community.Id} found in database. Update failed."); - - return; - } - - entity.Enabled = community.Enabled; - entity.Name = community.Name; - - await _dbContext.SaveChangesAsync(token); - } - - public async Task Delete(int id, CancellationToken token = default) - { - var community = await _dbContext.Communities - .SingleOrDefaultAsync(d => d.Id == id, token); - - if (community == null) - { - return false; - } - - _dbContext.Communities.Remove(community); - - await _dbContext.SaveChangesAsync(token); - - return true; - } -} diff --git a/examples/Udap.CA/Services/RootCertificateService.cs b/examples/Udap.CA/Services/RootCertificateService.cs deleted file mode 100644 index fffe95afc..000000000 --- a/examples/Udap.CA/Services/RootCertificateService.cs +++ /dev/null @@ -1,78 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Microsoft.EntityFrameworkCore; -using Udap.CA.DbContexts; -using Udap.CA.Mappers; - -namespace Udap.CA.Services; - -public class RootCertificateService -{ - private IUdapCaContext _dbContext; - private ILogger _logger; - - public RootCertificateService(IUdapCaContext dbContext, ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - - public async Task> Get(CancellationToken token = default) - { - var rootCertificates = await _dbContext.RootCertificates - .ToListAsync(cancellationToken: token); - - return rootCertificates.ToViewModels(); - } - - public async Task Create(ViewModel.RootCertificate rootCertificate, CancellationToken token = default) - { - var entity = rootCertificate.ToEntity(); - await _dbContext.RootCertificates.AddAsync(entity, token); - await _dbContext.SaveChangesAsync(token); - - return entity.ToViewModel(); - } - - public async Task Update(ViewModel.RootCertificate rootCertificate, CancellationToken token = default) - { - var entity = await _dbContext.RootCertificates - .Where(c => c.Id == rootCertificate.Id) - .SingleOrDefaultAsync(cancellationToken: token); - - if (entity == null) - { - _logger.LogDebug($"No Community Id {rootCertificate.Id} found in database. Update failed."); - - return; - } - - entity.Enabled = rootCertificate.Enabled; - - await _dbContext.SaveChangesAsync(token); - } - - public async Task Delete(int id, CancellationToken token = default) - { - var rootCertificate = await _dbContext.RootCertificates - .SingleOrDefaultAsync(d => d.Id == id, token); - - if (rootCertificate == null) - { - return false; - } - - _dbContext.RootCertificates.Remove(rootCertificate); - - await _dbContext.SaveChangesAsync(token); - - return true; - } -} diff --git a/examples/Udap.CA/Services/State/CommunityState.cs b/examples/Udap.CA/Services/State/CommunityState.cs deleted file mode 100644 index f40309e1d..000000000 --- a/examples/Udap.CA/Services/State/CommunityState.cs +++ /dev/null @@ -1,29 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using Udap.CA.ViewModel; - -namespace Udap.CA.Services.State -{ - public class CommunityState - { - public Community? Community { get; set; } - - public ICollection? RootCertificates { get; set;} - - public void SetState(Community value) { - Community = value; - } - - public void SetState(ICollection value) - { - RootCertificates = value; - } - } -} diff --git a/examples/Udap.CA/Shared/MainLayout.razor b/examples/Udap.CA/Shared/MainLayout.razor deleted file mode 100644 index 0add686ce..000000000 --- a/examples/Udap.CA/Shared/MainLayout.razor +++ /dev/null @@ -1,50 +0,0 @@ -@inherits LayoutComponentBase -@inject IJSRuntime JSRuntime; - - - - - - - - - - - - - - - - - Udap CA - - - - - - @Body - - - - -@code { - bool _drawerOpen = true; - - void DrawerToggle() - { - _drawerOpen = !_drawerOpen; - } - - async Task GoToIG() - { - await JSRuntime.InvokeVoidAsync("open", "http://hl7.org/fhir/us/udap-security/", "_blank"); - } - - async Task GoToUdapOrg() - { - await JSRuntime.InvokeVoidAsync("open", "http://udap.org/", "_blank"); - } -} \ No newline at end of file diff --git a/examples/Udap.CA/Shared/NavMenu.razor b/examples/Udap.CA/Shared/NavMenu.razor deleted file mode 100644 index ea5cd3d21..000000000 --- a/examples/Udap.CA/Shared/NavMenu.razor +++ /dev/null @@ -1,4 +0,0 @@ - - Home - Communities - diff --git a/examples/Udap.CA/Udap.CA.csproj b/examples/Udap.CA/Udap.CA.csproj deleted file mode 100644 index ea7ce687c..000000000 --- a/examples/Udap.CA/Udap.CA.csproj +++ /dev/null @@ -1,59 +0,0 @@ - - - - net10.0 - enable - enable - . - Linux - 91b73d76-16d8-4890-a7b4-1956983ccdd4 - false - - - - 0 - - - - 0 - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - Always - - - - diff --git a/examples/Udap.CA/ViewModel/Anchor.cs b/examples/Udap.CA/ViewModel/Anchor.cs deleted file mode 100644 index 7dc018a22..000000000 --- a/examples/Udap.CA/ViewModel/Anchor.cs +++ /dev/null @@ -1,29 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography.X509Certificates; - -namespace Udap.CA.ViewModel; - -public class Anchor -{ - public long Id { get; set; } - public bool Enabled { get; set; } - public string Subject { get; set; } = string.Empty; - public string SubjectAltName { get; set; } = string.Empty; - public string CertificateRevocation { get; set; } = "http://crl.fhircerts.net:7026/crl/SureFhir-Anchor.crl"; - public string CertificateAuthIssuerUri { get; set; } = "http://crl.fhircerts.net:7026/certs/intermediates/SureFhir-TestAnchor.cer"; - public X509Certificate2? Certificate { get; set; } - public string? Thumbprint { get; set; } - public DateTime? BeginDate { get; set; } - public DateTime? EndDate { get; set; } - - public virtual ICollection IssuedCertificates { get; set; } - public int RootCertificateId { get; set; } -} \ No newline at end of file diff --git a/examples/Udap.CA/ViewModel/Community.cs b/examples/Udap.CA/ViewModel/Community.cs deleted file mode 100644 index 68ce3a150..000000000 --- a/examples/Udap.CA/ViewModel/Community.cs +++ /dev/null @@ -1,24 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -namespace Udap.CA.ViewModel -{ - public class Community - { - public int Id { get; set; } - - public string Name { get; set; } = string.Empty; - - public bool Enabled { get; set; } - - public ICollection RootCertificates { get; set; } = new HashSet(); - - public bool ShowRootCertificates { get; set; } - } -} diff --git a/examples/Udap.CA/ViewModel/IssuedCertificate.cs b/examples/Udap.CA/ViewModel/IssuedCertificate.cs deleted file mode 100644 index 6be1b88b1..000000000 --- a/examples/Udap.CA/ViewModel/IssuedCertificate.cs +++ /dev/null @@ -1,24 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography.X509Certificates; - -namespace Udap.CA.ViewModel; - -public class IssuedCertificate -{ - public int Id { get; set; } - public bool Enabled { get; set; } - public string? Name { get; set; } - public string? Community { get; set; } - - public X509Certificate2? Certificate { get; set; } - public DateTime? BeginDate { get; set; } - public DateTime? EndDate { get; set; } -} \ No newline at end of file diff --git a/examples/Udap.CA/ViewModel/RootCertificate.cs b/examples/Udap.CA/ViewModel/RootCertificate.cs deleted file mode 100644 index 8b98f4c02..000000000 --- a/examples/Udap.CA/ViewModel/RootCertificate.cs +++ /dev/null @@ -1,30 +0,0 @@ -#region (c) 2023 Joseph Shook. All rights reserved. -// /* -// Authors: -// Joseph Shook Joseph.Shook@Surescripts.com -// -// See LICENSE in the project root for license information. -// */ -#endregion - -using System.Security.Cryptography.X509Certificates; - -namespace Udap.CA.ViewModel; - -public class RootCertificate -{ - public int Id { get; set; } - public int CommunityId { get; set; } - public bool Enabled { get; set; } - public string Name { get; set; } = string.Empty; - public X509Certificate2? Certificate { get; set; } - public string Secret { get; set; } = "udap-test"; - public string? Thumbprint { get; set; } - /// - /// Place where public certificate is hosted. - /// - public string Url { get; set; } = new Uri("http://crl.fhircerts.net/certs/SureFhirLabs_CA.cer").AbsoluteUri; - public DateTime? BeginDate { get; set; } = DateTime.Now; - public DateTime? EndDate { get; set; } = DateTime.Now.AddYears(10); - public ICollection? Anchors { get; set; } -} \ No newline at end of file diff --git a/examples/Udap.CA/_Imports.razor b/examples/Udap.CA/_Imports.razor deleted file mode 100644 index c525c20af..000000000 --- a/examples/Udap.CA/_Imports.razor +++ /dev/null @@ -1,12 +0,0 @@ -@using System.Net.Http -@using Microsoft.AspNetCore.Authorization -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.JSInterop -@using MudBlazor -@using Udap.CA -@using Udap.CA.Shared -@using Udap.CA.ViewModel diff --git a/examples/Udap.CA/appsettings.Development.json b/examples/Udap.CA/appsettings.Development.json deleted file mode 100644 index f75746a76..000000000 --- a/examples/Udap.CA/appsettings.Development.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "DetailedErrors": true, - - "Kestrel": { - "Certificates": { - "Default": { - "Path": "host.docker.internal.pfx", - "Password": "udap-test" - } - } - } -} diff --git a/examples/Udap.CA/appsettings.json b/examples/Udap.CA/appsettings.json deleted file mode 100644 index 1b185466d..000000000 --- a/examples/Udap.CA/appsettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Debug", - "Override": { - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } - }, - "AllowedHosts": "*", - - "ConnectionStrings": { - "DefaultConnection": "Data Source=Udap.CA.db;", - "local_sqlDb": "Data Source=.;Initial Catalog=Udap.CA.db;Integrated Security=true;Trusted_Connection=True;TrustServerCertificate=True;", - //"db_sqlite": "Filename=./Udap.db", - // - // Put this in secrets.json. TrustServerCertificate=True is set because I did not settup ssl connection. - // I am connecting through the gcp sql proxy to deploy this database - // - "gcp_udap_ca_sql_db": "Data Source=.,1483;User ID=xxxxxxx;Password=xxxxxxx;Initial Catalog=Udap_CA_db;TrustServerCertificate=True;" - } -} diff --git a/examples/Udap.CA/cloudbuild.yaml b/examples/Udap.CA/cloudbuild.yaml deleted file mode 100644 index e9e7b2c36..000000000 --- a/examples/Udap.CA/cloudbuild.yaml +++ /dev/null @@ -1,28 +0,0 @@ -steps: -# Build the container image -- name: 'gcr.io/cloud-builders/docker' - args: [ 'build', '-t', 'us-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/fhirlabs.udap.ca:$TAG_NAME', '--file', './Dockerfile', '.' ] - -# See:: https://cloud.google.com/build/docs/deploying-builds/deploy-cloud-run -# Push the container image to Container Registry -- name: 'gcr.io/cloud-builders/docker' - args: ['push', 'us-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/fhirlabs.udap.ca:$TAG_NAME'] - -# Deploy container image to Cloud Run -- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - entrypoint: gcloud - args: [ - 'run', 'deploy', 'fhirlabs-udap-ca', - '--region', 'us-west1', - '--image', 'us-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/fhirlabs.udap.ca:$TAG_NAME', - '--max-instances', '1', - '--concurrency', '5', - '--set-env-vars', 'GCLOUD_PROJECT=true,GCPDeploy=true', - '--vpc-connector', 'alloydb-connector', - '--vpc-egress', 'private-ranges-only', - '--ingress', 'internal-and-cloud-load-balancing', - '--set-secrets', '/secret/udap_ca_appsettings=udap_ca_appsettings:latest' -] - -images: -- 'us-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/fhirlabs.udap.ca:$TAG_NAME' \ No newline at end of file diff --git a/examples/Udap.CA/nuget.config b/examples/Udap.CA/nuget.config deleted file mode 100644 index 50e5b2dd0..000000000 --- a/examples/Udap.CA/nuget.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/examples/Udap.CA/wwwroot/favicon.ico b/examples/Udap.CA/wwwroot/favicon.ico deleted file mode 100644 index 123922366..000000000 Binary files a/examples/Udap.CA/wwwroot/favicon.ico and /dev/null differ diff --git a/examples/Udap.CA/wwwroot/images/hl7/icon-fhir-32.png b/examples/Udap.CA/wwwroot/images/hl7/icon-fhir-32.png deleted file mode 100644 index 6ac663493..000000000 Binary files a/examples/Udap.CA/wwwroot/images/hl7/icon-fhir-32.png and /dev/null differ diff --git a/examples/Udap.CA/wwwroot/scripts/AutoFocus.js b/examples/Udap.CA/wwwroot/scripts/AutoFocus.js deleted file mode 100644 index e2af00859..000000000 --- a/examples/Udap.CA/wwwroot/scripts/AutoFocus.js +++ /dev/null @@ -1,6 +0,0 @@ -var UdapCA = UdapCA || {}; -UdapCA.setFocus = function (id) { - - let e = document.getElementById(id); - e.click(); -}; \ No newline at end of file diff --git a/examples/Udap.Identity.Provider.2/Udap.Identity.Provider.2.csproj b/examples/Udap.Identity.Provider.2/Udap.Identity.Provider.2.csproj index b8ec7d933..2f692184c 100644 --- a/examples/Udap.Identity.Provider.2/Udap.Identity.Provider.2.csproj +++ b/examples/Udap.Identity.Provider.2/Udap.Identity.Provider.2.csproj @@ -37,9 +37,9 @@ - + - + @@ -48,8 +48,8 @@
- - + + diff --git a/examples/Udap.Identity.Provider/Udap.Identity.Provider.csproj b/examples/Udap.Identity.Provider/Udap.Identity.Provider.csproj index ef4b60d29..a821d3bc7 100644 --- a/examples/Udap.Identity.Provider/Udap.Identity.Provider.csproj +++ b/examples/Udap.Identity.Provider/Udap.Identity.Provider.csproj @@ -37,9 +37,9 @@ - + - + @@ -48,8 +48,8 @@ - - + + diff --git a/examples/Udap.Proxy.Server/README.md b/examples/Udap.Proxy.Server/README.md index 222b8e948..a6ed1bb46 100644 --- a/examples/Udap.Proxy.Server/README.md +++ b/examples/Udap.Proxy.Server/README.md @@ -1,28 +1,23 @@ -# UDAP Proxy Server +# UDAP Proxy Server -Build on dotnet [YARP](https://microsoft.github.io/reverse-proxy/) (Yet Another Reverse Proxy) and ASP.NET Core 8.0 +Built on dotnet [YARP](https://microsoft.github.io/reverse-proxy/) (Yet Another Reverse Proxy) and ASP.NET Core. +The proxy server sits in front of an existing FHIR server and provides UDAP metadata and SMART on FHIR metadata endpoints. The [Udap.Metadata.Server](https://www.nuget.org/packages/Udap.Metadata.Server) and [Udap.Smart.Metadata](https://www.nuget.org/packages/Udap.Smart.Metadata) packages are used to generate the metadata. YARP routes and clusters are configured in appsettings. -The proxy server is the source of a UDAP metadata endpoint and a SMART on FHIR metadata endpoint. -The [Udap.Metadata.Server](https://www.nuget.org/packages/Udap.Metadata.Server) and [Udap.Smart.Metadata](https://www.nuget.org/packages/Udap.Smart.Metadata) -packages are used to generate the metadata for the UDAP and SMART on FHIR endpoints, along with appsettings. +For a TEFCA-specific variant with exchange purpose validation and GCP integration, see [Tefca.Proxy.Server](../Tefca.Proxy.Server/). -YARP Routes and clusters are all configured in appsettings in this example. +## Important Concepts -## Important concepts. +The proxy has anonymous routes and UDAP secured routes. To access FHIR resources, the client must follow UDAP Dynamic Client Registration and obtain an access token. All other routes are anonymous. -The proxy has anonymous routes and UDAP secured routes. To access the FHIR resources, the client must follow UDAP Dynamic Client Registration and obtain an access token. +### Bearer Token -All other routes are anonymous. :spiral_notepad: Note: You will see a SMART endpoints. This is in anticipation of a future Udap.Auth.Server that also can facilitate SMART on FHIR clients. +In this proxy scenario your FHIR server is secured by some mechanism, most likely a standard bearer access token. The code implements a GCP ADC technique based on a GCP service account. There is also a simple AccessToken technique — set `ReverseProxy:Routes:Metadata:AccessToken` to the name of an environment variable supplying your access token. Or write your own. -Two interesting functiolities are implemented in this proxy server you may want to look at right away. +### Transforms - #### 1. **Bearer Token** - In this Proxy scenario your FHIR server will be secured by some mechanism. Most likely a standard Bearer Access token. The code implements a GCP ADC technique base on a GCP Service account. There is also a simple AccessToken technique. In the appsettings you can set ReverseProxy:Routes:Metadata:AccessToken value to the name of an environment variable that supplies your access token. Or write your own. - #### 2. **Transforms** - The URLs in the FHIR resources must be transformed. Specifically for things like paging. I have not put much time into this so I expect this will not be good enough. - +URLs in FHIR resources (e.g., paging links) must be transformed from backend URLs to proxy-facing URLs. ![Logical Diagram](./docs/udap-proxy-logical.svg) -You will of course need to configure for your FHIR server. This example does not include an accessible FHIR Server. Although it is the FHIR server used by fhirlabs.net and includes all the bits to deploy to a GCP Cloud Run application. - - +You will need to configure for your FHIR server. This example does not include an accessible FHIR Server, although it is the FHIR server used by fhirlabs.net and includes all the bits to deploy to a GCP Cloud Run application. diff --git a/examples/Udap.Proxy.Server/Udap.Proxy.Server.csproj b/examples/Udap.Proxy.Server/Udap.Proxy.Server.csproj index ee3a84d3c..f94b479aa 100644 --- a/examples/Udap.Proxy.Server/Udap.Proxy.Server.csproj +++ b/examples/Udap.Proxy.Server/Udap.Proxy.Server.csproj @@ -29,10 +29,10 @@ - - - - + + + + @@ -91,6 +91,9 @@ Always + + Always + Always @@ -118,7 +121,7 @@ Always - + Always diff --git a/examples/mTLS.Proxy.Server/mTLS.Proxy.Server.csproj b/examples/mTLS.Proxy.Server/mTLS.Proxy.Server.csproj index 3e0ba2006..2eb492399 100644 --- a/examples/mTLS.Proxy.Server/mTLS.Proxy.Server.csproj +++ b/examples/mTLS.Proxy.Server/mTLS.Proxy.Server.csproj @@ -26,7 +26,7 @@ - + diff --git a/global.json b/global.json index 53b4c2779..8ed26085c 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", + "version": "10.0.202", "rollForward": "latestFeature", "allowPrerelease": false } diff --git a/migrations/UdapDb.Postgres/SeedData.Auth.Server.cs b/migrations/UdapDb.Postgres/SeedData.Auth.Server.cs index 0eb57619a..6586b9bac 100644 --- a/migrations/UdapDb.Postgres/SeedData.Auth.Server.cs +++ b/migrations/UdapDb.Postgres/SeedData.Auth.Server.cs @@ -391,6 +391,45 @@ public static async Task EnsureSeedData(string connectionString, string cer } + // + // Community: TEFCA (urn:oid:2.16.840.1.113883.3.7204.1.5) + // + if (!udapContext.Communities.Any(c => c.Name == "urn:oid:2.16.840.1.113883.3.7204.1.5")) + { + var community = new Community { Name = "urn:oid:2.16.840.1.113883.3.7204.1.5" }; + community.Enabled = true; + community.Default = false; + udapContext.Communities.Add(community); + await udapContext.SaveChangesAsync(); + } + + // + // Anchor TEFCA_Community + // + var tefcaAnchor = new X509Certificate2( + Path.Combine(assemblyPath!, certStoreBasePath, "TEFCA_Community_Desk/TEFCA_Test_CA.cer")); + + if ((await clientRegistrationStore.GetAnchors("urn:oid:2.16.840.1.113883.3.7204.1.5")) + .All(a => a.Thumbprint != tefcaAnchor.Thumbprint)) + { + var community = udapContext.Communities.Single(c => c.Name == "urn:oid:2.16.840.1.113883.3.7204.1.5"); + + anchor = new Anchor + { + BeginDate = tefcaAnchor.NotBefore.ToUniversalTime(), + EndDate = tefcaAnchor.NotAfter.ToUniversalTime(), + Name = tefcaAnchor.Subject, + Community = community, + X509Certificate = tefcaAnchor.ToPemFormat(), + Thumbprint = tefcaAnchor.Thumbprint, + Enabled = true + }; + + udapContext.Anchors.Add(anchor); + await udapContext.SaveChangesAsync(); + } + + Func treatmentSpecification = r => r is "Patient" or "AllergyIntolerance" or "Condition" or "Encounter"; var scopeProperties = new Dictionary(); scopeProperties.Add("smart_version", "v1"); @@ -464,7 +503,7 @@ public static async Task EnsureSeedData(string connectionString, string cer // // launch/Patient // - if (configDbContext.IdentityResources.All(i => i.Name != "launch/patient")) + if (configDbContext.ApiScopes.All(i => i.Name != "launch/patient")) { var apiScope = new ApiScope("launch/patient"); apiScope.ShowInDiscoveryDocument = false; @@ -499,76 +538,78 @@ public static async Task EnsureSeedData(string connectionString, string cer private static async Task SeedFhirScopes( NpgsqlConfigurationDbContext configDbContext, HashSet? seedScopes, - Dictionary scopeProperties) + Dictionary scopeProperties) { - var apiScopes = configDbContext.ApiScopes - .Include(s => s.Properties) - .Select(s => s) - .ToList(); + if (seedScopes is null || seedScopes.Count == 0) + { + return; + } + + var existingScopeNames = configDbContext.ApiScopes + .Select(s => s.Name) + .ToHashSet(StringComparer.Ordinal); foreach (var scopeName in seedScopes.Where(s => s.StartsWith("system"))) { - if (!apiScopes.Any(s => - s.Name == scopeName && s.Properties.Exists(p => p.Key == "udap_prefix" && p.Value == "system"))) + if (existingScopeNames.Add(scopeName)) { var apiScope = new ApiScope(scopeName); apiScope.ShowInDiscoveryDocument = false; - + if (apiScope.Name.StartsWith("system/*.")) { apiScope.ShowInDiscoveryDocument = true; apiScope.Enabled = false; } - + apiScope.Properties.Add("udap_prefix", "system"); - + foreach (var scopeProperty in scopeProperties) { apiScope.Properties.Add(scopeProperty.Key, scopeProperty.Value); } - + configDbContext.ApiScopes.Add(apiScope.ToEntity()); } } foreach (var scopeName in seedScopes.Where(s => s.StartsWith("user"))) { - if (!apiScopes.Any(s => - s.Name == scopeName && s.Properties.Exists(p => p.Key == "udap_prefix" && p.Value == "user"))) + if (existingScopeNames.Add(scopeName)) { var apiScope = new ApiScope(scopeName); apiScope.ShowInDiscoveryDocument = false; - + if (apiScope.Name.StartsWith("user/*.")) { apiScope.ShowInDiscoveryDocument = true; apiScope.Enabled = false; } - + apiScope.Properties.Add("udap_prefix", "user"); - + foreach (var scopeProperty in scopeProperties) { apiScope.Properties.Add(scopeProperty.Key, scopeProperty.Value); } - + configDbContext.ApiScopes.Add(apiScope.ToEntity()); } } - foreach (var scopeName in seedScopes.Where(s => s.StartsWith("patient")).ToList()) + foreach (var scopeName in seedScopes.Where(s => s.StartsWith("patient"))) { - if (!apiScopes.Any(s => s.Name == scopeName && s.Properties.Exists(p => p.Key == "udap_prefix" && p.Value == "patient"))) + if (existingScopeNames.Add(scopeName)) { var apiScope = new ApiScope(scopeName); apiScope.ShowInDiscoveryDocument = false; - + if (apiScope.Name.StartsWith("patient/*.")) { apiScope.ShowInDiscoveryDocument = true; apiScope.Enabled = false; } - + apiScope.Properties.Add("udap_prefix", "patient"); foreach (var scopeProperty in scopeProperties)